X-Git-Url: https://git.distorted.org.uk/~mdw/catacomb-python/blobdiff_plain/b61e9efeda64d9736f767fd9a4b205716f7cc324..a3869542180f9bff989afc5b5f56e0930bc41991:/catacomb/pwsafe.py diff --git a/catacomb/pwsafe.py b/catacomb/pwsafe.py index 08e946b..97ad354 100644 --- a/catacomb/pwsafe.py +++ b/catacomb/pwsafe.py @@ -28,12 +28,35 @@ from __future__ import with_statement +import binascii as _B import errno as _E import os as _OS from cStringIO import StringIO as _StringIO import catacomb as _C -import gdbm as _G + +###-------------------------------------------------------------------------- +### Python version portability. + +def _iterkeys(dict): return dict.iterkeys() +def _itervalues(dict): return dict.itervalues() +def _iteritems(dict): return dict.iteritems() + +def _bin(text): return text +def _text(bin): return bin + +_NUL = _bin('\0') +_CIPHER = _bin('cipher:') +_MAC = _bin('mac:') + +def _with_metaclass(meta, *supers): + return meta("#" % meta.__name__, + supers or (object,), dict()) + +def _excval(): return SYS.exc_info()[1] + +_M600 = int("600", 8) +_M700 = int("700", 8) ###-------------------------------------------------------------------------- ### Text encoding utilities. @@ -81,10 +104,10 @@ def _dec_metaname(name): def _b64(s): """Encode S as base64, without newlines, and trimming `=' padding.""" - return s.encode('base64').translate(None, '\n=') + return _text(_B.b2a_base64(s)).replace('\n', '').rstrip('=') def _unb64(s): """Decode S as base64 with trimmed `=' padding.""" - return (s + '='*((4 - len(s))%4)).decode('base64') + return _B.a2b_base64(s + '='*((4 - len(s))%4)) def _enc_metaval(val): """Encode VAL as a metadata item value, returning the result.""" @@ -184,10 +207,10 @@ class PPK (Crypto): keys, indicating that a salt should be chosen randomly. """ if not salt: salt = _C.rand.block(h.hashsz) - tag = '%s\0%s' % (pp, salt) + tag = pp + _NUL + salt Crypto.__init__(me, c, h, m, - h().hash('cipher:' + tag).done(), - h().hash('mac:' + tag).done()) + h().hash(_CIPHER).hash(tag).done(), + h().hash(_MAC).hash(tag).done()) me.salt = salt ###-------------------------------------------------------------------------- @@ -213,9 +236,11 @@ class StorageBackendClass (type): Register a new concrete StorageBackend subclass. """ super(StorageBackendClass, me).__init__(name, supers, dict) - if me.NAME is not None: StorageBackend.register_concrete_subclass(me) + try: name = me.NAME + except AttributeError: pass + else: StorageBackend.register_concrete_subclass(me) -class StorageBackend (object): +class StorageBackend (_with_metaclass(StorageBackendClass)): """ I provide basic protocol for password storage backends. @@ -283,8 +308,6 @@ class StorageBackend (object): priority order when opening an existing database. """ - __metaclass__ = StorageBackendClass - NAME = None PRIO = 10 ## The registry of subclasses. @@ -309,7 +332,7 @@ class StorageBackend (object): @staticmethod def classes(): """Return an iterator over the concrete subclasses.""" - return StorageBackend.CLASSES.itervalues() + return _itervalues(StorageBackend.CLASSES) @staticmethod def open(file, writep = False): @@ -340,9 +363,9 @@ class StorageBackend (object): `_open' method, which is invoked in the usual case. """ super(StorageBackend, me).__init__(*args, **kw) - if me.NAME is None: raise ValueError, 'abstract class' + if me.NAME is None: raise ValueError('abstract class') if _magic is not None: _magic(me) - elif file is None: raise ValueError, 'missing file parameter' + elif file is None: raise ValueError('missing file parameter') else: me._open(file, writep) me._writep = writep me._livep = True @@ -362,12 +385,12 @@ class StorageBackend (object): def _check_live(me): """Raise an error if the receiver has been closed.""" - if not me._livep: raise ValueError, 'database is 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' + if not me._writep: raise ValueError('database is read-only') def _check_meta_name(me, name): """ @@ -377,7 +400,7 @@ class StorageBackend (object): password storage. """ if name.startswith('$'): - raise ValueError, "invalid metadata key `%s'" % name + raise ValueError("invalid metadata key `%s'" % name) ## Context protocol. @@ -404,7 +427,7 @@ class StorageBackend (object): me._check_meta_name(name) me._check_live() value = me._get_meta(name, default) - if value is StorageBackend.FAIL: raise KeyError, name + if value is StorageBackend.FAIL: raise KeyError(name) return value def put_meta(me, name, value): @@ -473,7 +496,7 @@ class StorageBackend (object): database is open for writing. """ me._check_write() - me._del_passwd(label, payload) + me._del_passwd(label) def iter_passwds(me): """ @@ -485,59 +508,184 @@ class StorageBackend (object): me._check_live() return me._iter_passwds() -class GDBMStorageBackend (StorageBackend): - """ - My instances store password data in a GDBM database. +try: import gdbm as _G +except ImportError: pass +else: + class GDBMStorageBackend (StorageBackend): + """ + My instances store password data in a GDBM database. - Metadata and password entries are mixed into the same database. The key - for a metadata item is simply its name; the key for a password entry is - the entry's label prefixed by `$', since we're guaranteed that no - metadata item name begins with `$'. - """ + Metadata and password entries are mixed into the same database. The key + for a metadata item is simply its name; the key for a password entry is + the entry's label prefixed by `$', since we're guaranteed that no + metadata item name begins with `$'. + """ - NAME = 'gdbm' + NAME = 'gdbm' - def _open(me, file, writep): - try: me._db = _G.open(file, writep and 'w' or 'r') - except _G.error, e: raise StorageBackendRefusal, e + def _open(me, file, writep): + try: me._db = _G.open(file, writep and 'w' or 'r') + except _G.error: raise StorageBackendRefusal(_excval()) - def _create(me, file): - me._db = _G.open(file, 'n', 0600) + def _create(me, file): + me._db = _G.open(file, 'n', _M600) - def _close(me, abruptp): - me._db.close() - me._db = None + def _close(me, abruptp): + me._db.close() + me._db = None - def _get_meta(me, name, default): - try: return me._db[name] - except KeyError: return default + def _get_meta(me, name, default): + try: return me._db[name] + except KeyError: return default - def _put_meta(me, name, value): - me._db[name] = value + def _put_meta(me, name, value): + me._db[name] = value - def _del_meta(me, name): - del me._db[name] + def _del_meta(me, name): + del me._db[name] - def _iter_meta(me): - k = me._db.firstkey() - while k is not None: - if not k.startswith('$'): yield k, me._db[k] - k = me._db.nextkey(k) + def _iter_meta(me): + k = me._db.firstkey() + while k is not None: + if not k.startswith('$'): yield k, me._db[k] + k = me._db.nextkey(k) - def _get_passwd(me, label): - return me._db['$' + label] + def _get_passwd(me, label): + return me._db['$' + label] - def _put_passwd(me, label, payload): - me._db['$' + label] = payload + def _put_passwd(me, label, payload): + me._db['$' + label] = payload - def _del_passwd(me, label): - del me._db['$' + label] + def _del_passwd(me, label): + del me._db['$' + label] - def _iter_passwds(me): - k = me._db.firstkey() - while k is not None: - if k.startswith('$'): yield k[1:], me._db[k] - k = me._db.nextkey(k) + def _iter_passwds(me): + k = me._db.firstkey() + while k is not None: + if k.startswith('$'): yield k[1:], me._db[k] + k = me._db.nextkey(k) + +try: import sqlite3 as _Q +except ImportError: pass +else: + class SQLiteStorageBackend (StorageBackend): + """ + I represent a password database stored in SQLite. + + Metadata and password items are stored in separate tables, so there's no + conflict. Some additional metadata is stored in the `meta' table, with + names beginning with `$' so as not to conflict with clients: + + $version The schema version of the table. + """ + + NAME = 'sqlite' + VERSION = 0 + + def _open(me, file, writep): + try: + me._db = _Q.connect(file) + ver = me._query_scalar( + "SELECT value FROM meta WHERE name = '$version'", + "version check") + except (_Q.DatabaseError, _Q.OperationalError): + raise StorageBackendRefusal(_excval()) + if ver is None: raise ValueError('database broken (missing $version)') + elif ver < me.VERSION: me._upgrade(ver) + elif ver > me.VERSION: raise ValueError \ + ('unknown database schema version (%d > %d)' % (ver, me.VERSION)) + + def _create(me, file): + fd = _OS.open(file, _OS.O_WRONLY | _OS.O_CREAT | _OS.O_EXCL, _M600) + _OS.close(fd) + try: + me._db = _Q.connect(file) + c = me._db.cursor() + c.execute(""" + CREATE TABLE meta ( + name TEXT PRIMARY KEY NOT NULL, + value BLOB NOT NULL); + """) + c.execute(""" + CREATE TABLE passwd ( + label BLOB PRIMARY KEY NOT NULL, + payload BLOB NOT NULL); + """) + c.execute(""" + INSERT INTO meta (name, value) VALUES ('$version', ?); + """, [me.VERSION]) + except: + try: _OS.unlink(file) + except OSError: pass + raise + + def _upgrade(me, ver): + """Upgrade the database from schema version VER.""" + assert False, 'how embarrassing' + + def _close(me, abruptp): + if not abruptp: me._db.commit() + me._db.close() + me._db = None + + def _fetch_scalar(me, c, what, default = None): + try: row = next(c) + except StopIteration: val = default + else: val, = row + try: row = next(c) + except StopIteration: pass + else: raise ValueError('multiple matching records for %s' % what) + return val + + def _query_scalar(me, query, what, default = None, args = []): + c = me._db.cursor() + c.execute(query, args) + return me._fetch_scalar(c, what, default) + + def _get_meta(me, name, default): + v = me._query_scalar("SELECT value FROM meta WHERE name = ?", + "metadata item `%s'" % name, + default = default, args = [name]) + if v is default: return v + else: return str(v) + + def _put_meta(me, name, value): + c = me._db.cursor() + c.execute("INSERT OR REPLACE INTO meta (name, value) VALUES (?, ?)", + [name, buffer(value)]) + + def _del_meta(me, name): + c = me._db.cursor() + c.execute("DELETE FROM meta WHERE name = ?", [name]) + if not c.rowcount: raise KeyError(name) + + def _iter_meta(me): + c = me._db.cursor() + c.execute("SELECT name, value FROM meta WHERE name NOT LIKE '$%'") + for k, v in c: yield k, str(v) + + def _get_passwd(me, label): + pld = me._query_scalar("SELECT payload FROM passwd WHERE label = ?", + "password", default = None, + args = [buffer(label)]) + if pld is None: raise KeyError(label) + return str(pld) + + def _put_passwd(me, label, payload): + c = me._db.cursor() + c.execute("INSERT OR REPLACE INTO passwd (label, payload) " + "VALUES (?, ?)", + [buffer(label), buffer(payload)]) + + def _del_passwd(me, label): + c = me._db.cursor() + c.execute("DELETE FROM passwd WHERE label = ?", [label]) + if not c.rowcount: raise KeyError(label) + + def _iter_passwds(me): + c = me._db.cursor() + c.execute("SELECT label, payload FROM passwd") + for k, v in c: yield str(k), str(v) class PlainTextBackend (StorageBackend): """ @@ -591,7 +739,7 @@ class PlainTextBackend (StorageBackend): me._dirtyp = False super(PlainTextBackend, me).__init__(*args, **kw) - def _create_file(me, file, mode = 0600, freshp = False): + def _create_file(me, file, mode = _M600, freshp = False): """ Make sure FILE exists, creating it with the given MODE if necessary. @@ -643,7 +791,7 @@ class PlainTextBackend (StorageBackend): if not line or line.startswith('#'): continue me._parse_line(line) - def _write_file(me, file, writebody, mode = 0600, magic = None): + def _write_file(me, file, writebody, mode = _M600, magic = None): """ Update FILE atomically. @@ -665,7 +813,7 @@ class PlainTextBackend (StorageBackend): def _write_meta(me, f, prefix = ''): """Write the metadata records to F, each with the given PREFIX.""" f.write('\n## Metadata.\n') - for k, v in me._meta.iteritems(): + for k, v in _iteritems(me._meta): f.write('%s%s=%s\n' % (prefix, _enc_metaname(k), _enc_metaval(v))) def _get_meta(me, name, default): @@ -677,7 +825,7 @@ class PlainTextBackend (StorageBackend): me._mark_dirty() del me._meta[name] def _iter_meta(me): - return me._meta.iteritems() + return _iteritems(me._meta) def _parse_passwd(me, line): """Parse LINE as a password LABEL=PAYLOAD pair, and updates `_pw'.""" @@ -687,7 +835,7 @@ class PlainTextBackend (StorageBackend): def _write_passwd(me, f, prefix = ''): """Write the password records to F, each with the given PREFIX.""" f.write('\n## Password data.\n') - for k, v in me._pw.iteritems(): + for k, v in _iteritems(me._pw): f.write('%s%s=%s\n' % (prefix, _b64(k), _b64(v))) def _get_passwd(me, label): @@ -699,7 +847,7 @@ class PlainTextBackend (StorageBackend): me._mark_dirty() del me._pw[str(label)] def _iter_passwds(me): - return me._pw.iteritems() + return _iteritems(me._pw) class FlatFileStorageBackend (PlainTextBackend): """ @@ -786,9 +934,9 @@ class DirectoryStorageBackend (PlainTextBackend): me._parse_meta(line) def _create(me, file): - _OS.mkdir(file, 0700) - _OS.mkdir(_OS.path.join(file, 'pw'), 0700) - _OS.mkdir(_OS.path.join(file, 'tmp'), 0700) + _OS.mkdir(file, _M700) + _OS.mkdir(_OS.path.join(file, 'pw'), _M700) + _OS.mkdir(_OS.path.join(file, 'tmp'), _M700) me._mark_dirty() me._dir = file @@ -802,21 +950,21 @@ class DirectoryStorageBackend (PlainTextBackend): def _get_passwd(me, label): try: f = open(me._pwfile(label), 'rb') - except (OSError, IOError), e: - if e.errno == _E.ENOENT: raise KeyError, label + except (OSError, IOError): + if _excval().errno == _E.ENOENT: raise KeyError(label) else: raise with f: return f.read() def _put_passwd(me, label, payload): new = me._pwfile(label, 'tmp') - fd = _OS.open(new, _OS.O_WRONLY | _OS.O_CREAT | _OS.O_TRUNC, 0600) + fd = _OS.open(new, _OS.O_WRONLY | _OS.O_CREAT | _OS.O_TRUNC, _M600) _OS.close(fd) with open(new, 'wb') as f: f.write(payload) _OS.rename(new, me._pwfile(label)) def _del_passwd(me, label): try: _OS.remove(me._pwfile(label)) - except (OSError, IOError), e: - if e == _E.ENOENT: raise KeyError, label + except (OSError, IOError): + if _excval().errno == _E.ENOENT: raise KeyError(label) else: raise def _iter_passwds(me): pw = _OS.path.join(me._dir, 'pw') @@ -889,7 +1037,7 @@ class PW (object): raise me.ck = b.getblk16() me.mk = b.getblk16() - if not b.endp: raise ValueError, 'trailing junk' + if not b.endp: raise ValueError('trailing junk') ## Set the key, and stash it and the tag-hashing secret. me.k = Crypto(c, h, m, me.ck, me.mk) @@ -967,7 +1115,7 @@ class PW (object): def __getitem__(me, 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 + except KeyError: raise KeyError(key) def __setitem__(me, key, value): """Associate the password VALUE with the KEY.""" @@ -976,7 +1124,7 @@ class PW (object): def __delitem__(me, key): """Forget all about the KEY.""" try: me.db.del_passwd(me.keyxform(key)) - except KeyError: raise KeyError, key + except KeyError: raise KeyError(key) def __iter__(me): """Iterate over the known password tags."""