X-Git-Url: https://git.distorted.org.uk/~mdw/catacomb-python/blobdiff_plain/af861fb77b2f012cb6534626544ff97f02d72251..1d93191e19f43bce5b8f3bd3447c755c1d615f56:/catacomb/pwsafe.py diff --git a/catacomb/pwsafe.py b/catacomb/pwsafe.py index 7b737b8..cfbac7e 100644 --- a/catacomb/pwsafe.py +++ b/catacomb/pwsafe.py @@ -33,7 +33,6 @@ import os as _OS from cStringIO import StringIO as _StringIO import catacomb as _C -import gdbm as _G ###-------------------------------------------------------------------------- ### Text encoding utilities. @@ -473,7 +472,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 +484,185 @@ 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, e: raise StorageBackendRefusal, e - def _create(me, file): - me._db = _G.open(file, 'n', 0600) + def _create(me, file): + me._db = _G.open(file, 'n', 0600) - 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), e: + raise StorageBackendRefusal, e + 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, 0600) + _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): """ @@ -753,6 +878,77 @@ class FlatFileStorageBackend (PlainTextBackend): me._write_meta(f) me._write_passwd(f, '$') +class DirectoryStorageBackend (PlainTextBackend): + """ + I maintain a password database in a directory, with one file per password. + + This makes password databases easy to maintain in a revision-control system + such as Git. + + The directory is structured as follows. + + dir/meta Contains metadata, similar to the `FlatFileBackend'. + + dir/pw/LABEL Contains the (raw binary) payload for the given password + LABEL (base64-encoded, without the useless `=' padding, and + with `/' replaced by `.'). + + dir/tmp/ Contains temporary files used by the implementation. + """ + + NAME = 'dir' + METAMAGIC = '### pwsafe password directory metadata' + + def _open(me, file, writep): + if not _OS.path.isdir(file) or \ + not _OS.path.isdir(_OS.path.join(file, 'pw')) or \ + not _OS.path.isdir(_OS.path.join(file, 'tmp')) or \ + not _OS.path.isfile(_OS.path.join(file, 'meta')): + raise StorageBackendRefusal + me._dir = file + me._parse_file(_OS.path.join(file, 'meta'), magic = me.METAMAGIC) + def _parse_line(me, line): + 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) + me._mark_dirty() + me._dir = file + + def _close(me, abruptp): + if not abruptp and me._dirtyp: + me._write_file(_OS.path.join(me._dir, 'meta'), + me._write_meta, magic = me.METAMAGIC) + + def _pwfile(me, label, dir = 'pw'): + return _OS.path.join(me._dir, dir, _b64(label).replace('/', '.')) + def _get_passwd(me, label): + try: + f = open(me._pwfile(label), 'rb') + except (OSError, IOError), e: + if e.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) + _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 + else: raise + def _iter_passwds(me): + pw = _OS.path.join(me._dir, 'pw') + for i in _OS.listdir(pw): + with open(_OS.path.join(pw, i), 'rb') as f: pld = f.read() + yield _unb64(i.replace('.', '/')), pld + ###-------------------------------------------------------------------------- ### Password storage.