From af861fb77b2f012cb6534626544ff97f02d72251 Mon Sep 17 00:00:00 2001 Message-Id: From: Mark Wooding Date: Sun, 24 May 2015 19:33:50 +0100 Subject: [PATCH] catacomb/pwsafe.py: New FlatFileStorageBackend class. Organization: Straylight/Edgeware From: Mark Wooding No external dependencies required. --- catacomb/pwsafe.py | 277 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) diff --git a/catacomb/pwsafe.py b/catacomb/pwsafe.py index 8b68182..7b737b8 100644 --- a/catacomb/pwsafe.py +++ b/catacomb/pwsafe.py @@ -28,11 +28,74 @@ from __future__ import with_statement +import errno as _E import os as _OS +from cStringIO import StringIO as _StringIO import catacomb as _C import gdbm as _G +###-------------------------------------------------------------------------- +### Text encoding utilities. + +def _literalp(s): + """ + Answer whether S can be represented literally. + + If True, then S can be stored literally, as a metadata item name or + value; if False, then S requires some kind of encoding. + """ + return all(ch.isalnum() or ch in '-_:' for ch in s) + +def _enc_metaname(name): + """Encode NAME as a metadata item name, returning the result.""" + if _literalp(name): + return name + else: + sio = _StringIO() + sio.write('!') + for ch in name: + if _literalp(ch): sio.write(ch) + elif ch == ' ': sio.write('+') + else: sio.write('%%%02x' % ord(ch)) + return sio.getvalue() + +def _dec_metaname(name): + """Decode NAME as a metadata item name, returning the result.""" + if not name.startswith('!'): + return name + else: + sio = _StringIO() + i, n = 1, len(name) + while i < n: + ch = name[i] + i += 1 + if ch == '+': + sio.write(' ') + elif ch == '%': + sio.write(chr(int(name[i:i + 2], 16))) + i += 2 + else: + sio.write(ch) + return sio.getvalue() + +def _b64(s): + """Encode S as base64, without newlines, and trimming `=' padding.""" + return s.encode('base64').translate(None, '\n=') +def _unb64(s): + """Decode S as base64 with trimmed `=' padding.""" + return (s + '='*((4 - len(s))%4)).decode('base64') + +def _enc_metaval(val): + """Encode VAL as a metadata item value, returning the result.""" + if _literalp(val): return val + else: return '?' + _b64(val) + +def _dec_metaval(val): + """Decode VAL as a metadata item value, returning the result.""" + if not val.startswith('?'): return val + else: return _unb64(val[1:]) + ###-------------------------------------------------------------------------- ### Underlying cryptography. @@ -476,6 +539,220 @@ class GDBMStorageBackend (StorageBackend): if k.startswith('$'): yield k[1:], me._db[k] k = me._db.nextkey(k) +class PlainTextBackend (StorageBackend): + """ + I'm a utility base class for storage backends which use plain text files. + + I provide subclasses with the following capabilities. + + * Creating files, with given modes, optionally ensuring that the file + doesn't exist already. + + * Parsing flat text files, checking leading magic, skipping comments, and + providing standard encodings of troublesome characters and binary + strings in metadata and password records. See below. + + * Maintenance of metadata and password records in in-memory dictionaries, + with ready implementations of the necessary StorageBackend subclass + responsibility methods. (Subclasses can override these if they want to + make different arrangements.) + + Metadata records are written with an optional prefix string chosen by the + caller, followed by a `NAME=VALUE' pair. The NAME is form-urlencoded and + prefixed with `!' if it contains strange characters; the VALUE is base64- + encoded (without the pointless trailing `=' padding) and prefixed with `?' + if necessary. + + Password records are written with an optional prefix string chosen by the + caller, followed by a LABEL=PAYLOAD pair, both of which are base64-encoded + (without padding). + + The following attributes are available for subclasses: + + _meta Dictionary mapping metadata item names to their values. + Populated by `_parse_meta' and managed by `_get_meta' and + friends. + + _pw Dictionary mapping password labels to encrypted payloads. + Populated by `_parse_passwd' and managed by `_get_passwd' and + friends. + + _dirtyp Boolean: set if either of the dictionaries has been modified. + """ + + def __init__(me, *args, **kw): + """ + Hook for initialization. + + Sets up the published instance attributes. + """ + me._meta = {} + me._pw = {} + me._dirtyp = False + super(PlainTextBackend, me).__init__(*args, **kw) + + def _create_file(me, file, mode = 0600, freshp = False): + """ + Make sure FILE exists, creating it with the given MODE if necessary. + + If FRESHP is true, then make sure the file did not exist previously. + Return a file object for the newly created file. + """ + flags = _OS.O_CREAT | _OS.O_WRONLY + if freshp: flags |= _OS.O_EXCL + else: flags |= _OS.O_TRUNC + fd = _OS.open(file, flags, mode) + return _OS.fdopen(fd, 'w') + + def _mark_dirty(me): + """ + Set the `_dirtyp' flag. + + Subclasses might find it useful to intercept this method. + """ + me._dirtyp = True + + def _eqsplit(me, line): + """ + Extract the KEY, VALUE pair from a LINE of the form `KEY=VALUE'. + + Raise `ValueError' if there is no `=' in the LINE. + """ + eq = line.index('=') + return line[:eq], line[eq + 1:] + + def _parse_file(me, file, magic = None): + """ + Parse a FILE. + + Specifically: + + * Raise `StorageBackendRefusal' if that the first line doesn't match + MAGIC (if provided). MAGIC should not contain the terminating + newline. + + * Ignore comments (beginning `#') and blank lines. + + * Call `_parse_line' (provided by the subclass) for other lines. + """ + with open(file, 'r') as f: + if magic is not None: + if f.readline().rstrip('\n') != magic: raise StorageBackendRefusal + for line in f: + line = line.rstrip('\n') + if not line or line.startswith('#'): continue + me._parse_line(line) + + def _write_file(me, file, writebody, mode = 0600, magic = None): + """ + Update FILE atomically. + + The newly created file will have the given MODE. If MAGIC is given, then + write that as the first line. Calls WRITEBODY(F) to write the main body + of the file where F is a file object for the new file. + """ + new = file + '.new' + with me._create_file(new, mode) as f: + if magic is not None: f.write(magic + '\n') + writebody(f) + _OS.rename(new, file) + + def _parse_meta(me, line): + """Parse LINE as a metadata NAME=VALUE pair, and updates `_meta'.""" + k, v = me._eqsplit(line) + me._meta[_dec_metaname(k)] = _dec_metaval(v) + + 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(): + f.write('%s%s=%s\n' % (prefix, _enc_metaname(k), _enc_metaval(v))) + + def _get_meta(me, name, default): + return me._meta.get(name, default) + def _put_meta(me, name, value): + me._mark_dirty() + me._meta[name] = value + def _del_meta(me, name): + me._mark_dirty() + del me._meta[name] + def _iter_meta(me): + return me._meta.iteritems() + + def _parse_passwd(me, line): + """Parse LINE as a password LABEL=PAYLOAD pair, and updates `_pw'.""" + k, v = me._eqsplit(line) + me._pw[_unb64(k)] = _unb64(v) + + 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(): + f.write('%s%s=%s\n' % (prefix, _b64(k), _b64(v))) + + def _get_passwd(me, label): + return me._pw[str(label)] + def _put_passwd(me, label, payload): + me._mark_dirty() + me._pw[str(label)] = payload + def _del_passwd(me, label): + me._mark_dirty() + del me._pw[str(label)] + def _iter_passwds(me): + return me._pw.iteritems() + +class FlatFileStorageBackend (PlainTextBackend): + """ + I maintain a password database in a plain text file. + + The text file consists of lines, as follows. + + * Empty lines, and lines beginning with `#' (in the leftmost column only) + are ignored. + + * Lines of the form `$LABEL=PAYLOAD' store password data. Both LABEL and + PAYLOAD are base64-encoded, without `=' padding. + + * Lines of the form `NAME=VALUE' store metadata. If the NAME contains + characters other than alphanumerics, hyphens, underscores, and colons, + then it is form-urlencoded, and prefixed wth `!'. If the VALUE + contains such characters, then it is base64-encoded, without `=' + padding, and prefixed with `?'. + + * Other lines are erroneous. + + The file is rewritten from scratch when it's changed: any existing + commentary is lost, and items may be reordered. There is no file locking, + but the file is updated atomically, by renaming. + + It is expected that the FlatFileStorageBackend is used mostly for + diagnostics and transfer, rather than for a live system. + """ + + NAME = 'flat' + PRIO = 0 + MAGIC = '### pwsafe password database' + + def _open(me, file, writep): + if not _OS.path.isfile(file): raise StorageBackendRefusal + me._parse_file(file, magic = me.MAGIC) + def _parse_line(me, line): + if line.startswith('$'): me._parse_passwd(line[1:]) + else: me._parse_meta(line) + + def _create(me, file): + with me._create_file(file, freshp = True) as f: pass + me._file = file + me._mark_dirty() + + def _close(me, abruptp): + if not abruptp and me._dirtyp: + me._write_file(me._file, me._write_body, magic = me.MAGIC) + + def _write_body(me, f): + me._write_meta(f) + me._write_passwd(f, '$') + ###-------------------------------------------------------------------------- ### Password storage. -- [mdw]