From d1c45f5c79fda35ff9f8fd7d90f345dba3de4eb8 Mon Sep 17 00:00:00 2001 From: Mark Wooding Date: Sat, 24 May 2014 14:00:03 +0100 Subject: [PATCH] pwsafe, catacomb/pwsafe.py: Documentation and cleanup. Nothing very major yet: some whitespace fettling, some comments added, and some code reordered. Also added copyright headers, and docstrings. There's a lot more work to do on this: it's in a really sorry state. --- catacomb/pwsafe.py | 288 +++++++++++++++++++++++++++++++++++++++++++++++------ pwsafe | 129 ++++++++++++++++++------ 2 files changed, 355 insertions(+), 62 deletions(-) mode change 100755 => 100644 pwsafe diff --git a/catacomb/pwsafe.py b/catacomb/pwsafe.py index bfd4e86..da311c7 100644 --- a/catacomb/pwsafe.py +++ b/catacomb/pwsafe.py @@ -1,18 +1,142 @@ -# -*-python-*- +### -*-python-*- +### +### Management of a secure password database +### +### (c) 2005 Straylight/Edgeware +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of the Python interface to Catacomb. +### +### Catacomb/Python is free software; you can redistribute it and/or modify +### it under the terms of the GNU General Public License as published by +### the Free Software Foundation; either version 2 of the License, or +### (at your option) any later version. +### +### Catacomb/Python is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU General Public License for more details. +### +### You should have received a copy of the GNU General Public License along +### with Catacomb/Python; if not, write to the Free Software Foundation, +### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +###-------------------------------------------------------------------------- +### Imported modules. import catacomb as _C import gdbm as _G import struct as _S +###-------------------------------------------------------------------------- +### Utilities. + +class Buffer (object): + """ + I am a simple gadget for parsing binary strings. + + You should use Catacomb's ReadBuffer instead. + """ + + def __init__(me, s): + """ + Initialize the buffer with a string S. + """ + me.str = s + me.i = 0 + + def get(me, n): + """ + Fetch and return the next N bytes from the buffer. + """ + i = me.i + if n + i > len(me.str): + raise IndexError, 'buffer underflow' + me.i += n + return me.str[i:i + n] + + def getbyte(me): + """ + Fetch and return (as a small integer) the next byte from the buffer. + """ + return ord(me.get(1)) + + def unpack(me, fmt): + """ + Unpack a structure described by FMT from the next bytes of the buffer. + + Return a tuple containing the unpacked items. + """ + return _S.unpack(fmt, me.get(_S.calcsize(fmt))) + + def getstring(me): + """ + Fetch and return a counted string from the buffer. + + The string is expected to be preceded by its 16-bit length, in network + byte order. + """ + return me.get(me.unpack('>H')[0]) + + def checkend(me): + """ + Raise an error if the buffer has not been completely consumed. + """ + if me.i != len(me.str): + raise ValueError, 'junk at end of buffer' + +def _wrapstr(s): + """ + Prefix the string S with its 16-bit length. + + It can be read using Buffer.getstring. You should use Catacomb's + WriteBuffer.putblk16() function instead. + """ + return _S.pack('>H', len(s)) + s + +###-------------------------------------------------------------------------- +### Underlying cryptography. + class DecryptError (Exception): + """ + I represent a failure to decrypt a message. + + Usually this means that someone used the wrong key, though it can also + mean that a ciphertext has been modified. + """ pass class Crypto (object): + """ + I represent a symmetric crypto transform. + + There's currently only one transform implemented, which is the obvious + generic-composition construction: given a message m, and keys K0 and K1, we + choose an IV v, and compute: + + * y = v || E(K0, v; m) + * t = M(K1; y) + + The final ciphertext is t || y. + """ + def __init__(me, c, h, m, ck, mk): + """ + Initialize the Crypto object with a given algorithm selection and keys. + + We need a GCipher subclass C, a GHash subclass H, a GMAC subclass M, and + keys CK and MK for C and M respectively. + """ me.c = c(ck) me.m = m(mk) me.h = h + def encrypt(me, pt): + """ + Encrypt the message PT and return the resulting ciphertext. + """ if me.c.__class__.blksz: iv = _C.rand.block(me.c.__class__.blksz) me.c.setiv(iv) @@ -22,6 +146,11 @@ class Crypto (object): t = me.m().hash(y).done() return t + y def decrypt(me, ct): + """ + Decrypt the ciphertext CT, returning the plaintext. + + Raises DecryptError if anything goes wrong. + """ t = ct[:me.m.__class__.tagsz] y = ct[me.m.__class__.tagsz:] if t != me.m().hash(y).done(): @@ -31,57 +160,114 @@ class Crypto (object): return me.c.decrypt(y[me.c.__class__.blksz:]) class PPK (Crypto): + """ + I represent a crypto transform whose keys are derived from a passphrase. + + The password is salted and hashed; the salt is available as the `salt' + attribute. + """ + def __init__(me, pp, c, h, m, salt = None): + """ + Initialize the PPK object with a passphrase and algorithm selection. + + We want a passphrase PP, a GCipher subclass C, a GHash subclass H, a GMAC + subclass M, and a SALT. The SALT may be None, if we're generating new + keys, indicating that a salt should be chosen randomly. + """ if not salt: salt = _C.rand.block(h.hashsz) tag = '%s\0%s' % (pp, salt) Crypto.__init__(me, c, h, m, - h().hash('cipher:' + tag).done(), - h().hash('mac:' + tag).done()) + h().hash('cipher:' + tag).done(), + h().hash('mac:' + tag).done()) me.salt = salt -class Buffer (object): - def __init__(me, s): - me.str = s - me.i = 0 - def get(me, n): - i = me.i - if n + i > len(me.str): - raise IndexError, 'buffer underflow' - me.i += n - return me.str[i:i + n] - def getbyte(me): - return ord(me.get(1)) - def unpack(me, fmt): - return _S.unpack(fmt, me.get(_S.calcsize(fmt))) - def getstring(me): - return me.get(me.unpack('>H')[0]) - def checkend(me): - if me.i != len(me.str): - raise ValueError, 'junk at end of buffer' - -def _wrapstr(s): - return _S.pack('>H', len(s)) + s +###-------------------------------------------------------------------------- +### Password storage. class PWIter (object): + """ + I am an iterator over items in a password database. + + I implement the usual Python iteration protocol. + """ + def __init__(me, pw): + """ + Initialize a PWIter object, to fetch items from PW. + """ me.pw = pw me.k = me.pw.db.firstkey() + def next(me): + """ + Return the next tag from the database. + + Raises StopIteration if there are no more tags. + """ k = me.k while True: if k is None: - raise StopIteration + raise StopIteration if k[0] == '$': - break + break k = me.pw.db.nextkey(k) me.k = me.pw.db.nextkey(k) return me.pw.unpack(me.pw.db[k])[0] + class PW (object): + """ + I represent a secure (ish) password store. + + I can store short secrets, associated with textual names, in a way which + doesn't leak too much information about them. + + 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. + + cipher Names the Catacomb cipher selected. + + hash Names the Catacomb hash function selected. + + key Cipher and MAC keys, each prefixed by a 16-bit big-endian + length and concatenated, encrypted using the master + passphrase. + + mac Names the Catacomb message authentication code selected. + + magic A magic string for obscuring password tag names. + + salt The salt for hashing the passphrase. + + 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. + """ + def __init__(me, file, mode = 'r'): + """ + Initialize a PW object from the GDBM database in FILE. + + MODE can be `r' for read-only access to the underlying database, or `w' + for read-write access. Requests the database password from the Pixie, + which may cause interaction. + """ + + ## Open the database. me.db = _G.open(file, mode) + + ## Find out what crypto to use. c = _C.gcciphers[me.db['cipher']] h = _C.gchashes[me.db['hash']] m = _C.gcmacs[me.db['mac']] + + ## Request the passphrase and extract the master keys. tag = me.db['tag'] ppk = PPK(_C.ppread(tag), c, h, m, me.db['salt']) try: @@ -92,39 +278,81 @@ class PW (object): me.ck = buf.getstring() me.mk = buf.getstring() buf.checkend() + + ## 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']) + 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() + def changepp(me): + """ + Change the database password. + + Requests the new password from the Pixie, which will probably cause + interaction. + """ tag = me.db['tag'] _C.ppcancel(tag) ppk = PPK(_C.ppread(tag, _C.PMODE_VERIFY), - me.k.c.__class__, me.k.h, me.k.m.__class__) + me.k.c.__class__, me.k.h, me.k.m.__class__) me.db['key'] = ppk.encrypt(_wrapstr(me.ck) + _wrapstr(me.mk)) me.db['salt'] = ppk.salt + def pack(me, key, value): + """ + Pack the KEY and VALUE into a ciphertext, and return it. + """ w = _wrapstr(key) + _wrapstr(value) pl = (len(w) + 255) & ~255 w += '\0' * (pl - len(w)) return me.k.encrypt(w) - def unpack(me, p): - buf = Buffer(me.k.decrypt(p)) + + def unpack(me, ct): + """ + Unpack a ciphertext CT and return a (KEY, VALUE) pair. + + Might raise DecryptError, of course. + """ + buf = Buffer(me.k.decrypt(ct)) key = buf.getstring() value = buf.getstring() return key, value + + ## 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 + def __setitem__(me, key, value): + """ + Associate the password VALUE with the KEY. + """ me.db[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 + def __iter__(me): + """ + Iterate over the known password tags. + """ return PWIter(me) +###----- That's all, folks -------------------------------------------------- diff --git a/pwsafe b/pwsafe old mode 100755 new mode 100644 index 52f9abb..a5d15ea --- a/pwsafe +++ b/pwsafe @@ -1,8 +1,32 @@ #! /usr/bin/python -# -*-python-*- +### -*-python-*- +### +### Tool for maintaining a secure-ish password database +### +### (c) 2005 Straylight/Edgeware +### + +###----- Licensing notice --------------------------------------------------- +### +### This file is part of the Python interface to Catacomb. +### +### Catacomb/Python is free software; you can redistribute it and/or modify +### it under the terms of the GNU General Public License as published by +### the Free Software Foundation; either version 2 of the License, or +### (at your option) any later version. +### +### Catacomb/Python is distributed in the hope that it will be useful, +### but WITHOUT ANY WARRANTY; without even the implied warranty of +### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +### GNU General Public License for more details. +### +### You should have received a copy of the GNU General Public License +### along with Catacomb/Python; if not, write to the Free Software Foundation, +### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +###--------------------------------------------------------------------------- +### Imported modules. -import catacomb as C -from catacomb.pwsafe import * import gdbm as G from os import environ from sys import argv, exit, stdin, stdout, stderr @@ -10,22 +34,57 @@ from getopt import getopt, GetoptError from fnmatch import fnmatch import re +import catacomb as C +from catacomb.pwsafe import * + +###-------------------------------------------------------------------------- +### Utilities. + +## The program name. prog = re.sub(r'^.*[/\\]', '', argv[0]) + def moan(msg): + """Issue a warning message MSG.""" print >>stderr, '%s: %s' % (prog, msg) + def die(msg): + """Report MSG as a fatal error, and exit.""" moan(msg) exit(1) -if 'PWSAFE' in environ: - file = environ['PWSAFE'] -else: - file = '%s/.pwsafe' % environ['HOME'] +def chomp(pp): + """Return the string PP, without its trailing newline if it has one.""" + if len(pp) > 0 and pp[-1] == '\n': + pp = pp[:-1] + return pp + +def asciip(s): + """Answer whether all of the characters of S are plain ASCII.""" + for ch in s: + if ch < ' ' or ch > '~': return False + return True + +def present(s): + """ + Return a presentation form of the string S. + + If S is plain ASCII, then return S unchanged; otherwise return it as one of + Catacomb's ByteString objects. + """ + if asciip(s): return s + return C.ByteString(s) + +###-------------------------------------------------------------------------- +### Subcommand implementations. def cmd_create(av): + + ## Default crypto-primitive selections. cipher = 'blowfish-cbc' hash = 'rmd160' mac = None + + ## Parse the options. try: opts, args = getopt(av, 'c:h:m:', ['cipher=', 'mac=', 'hash=']) except GetoptError: @@ -45,7 +104,8 @@ def cmd_create(av): tag = args[0] else: tag = 'pwsafe' - db = G.open(file, 'n', 0600) + + ## Choose a passphrase, and generate master keys. pp = C.ppread(tag, C.PMODE_VERIFY) if not mac: mac = hash + '-hmac' c = C.gcciphers[cipher] @@ -55,6 +115,9 @@ def cmd_create(av): ck = C.rand.block(c.keysz.default) mk = C.rand.block(m.keysz.default) k = Crypto(c, h, m, ck, mk) + + ## Set up the database, storing the basic information we need. + db = G.open(file, 'n', 0600) db['tag'] = tag db['salt'] = ppk.salt db['cipher'] = cipher @@ -63,11 +126,6 @@ def cmd_create(av): db['key'] = ppk.encrypt(wrapstr(ck) + wrapstr(mk)) db['magic'] = k.encrypt(C.rand.block(h.hashsz)) -def chomp(pp): - if len(pp) > 0 and pp[-1] == '\n': - pp = pp[:-1] - return pp - def cmd_changepp(av): if len(av) != 0: return 1 @@ -150,13 +208,6 @@ def cmd_del(av): except KeyError, exc: die('Password `%s\' not found.' % exc.args[0]) -def asciip(s): - for ch in s: - if ch < ' ' or ch > '~': return False - return True -def present(s): - if asciip(s): return s - return C.ByteString(s) def cmd_dump(av): db = gdbm.open(file, 'r') k = db.firstkey() @@ -166,20 +217,25 @@ def cmd_dump(av): k = db.nextkey(k) commands = { 'create': [cmd_create, - '[-c CIPHER] [-h HASH] [-m MAC] [PP-TAG]'], - 'find' : [cmd_find, 'LABEL'], - 'store' : [cmd_store, 'LABEL [VALUE]'], - 'list' : [cmd_list, '[GLOB-PATTERN]'], - 'changepp' : [cmd_changepp, ''], - 'copy' : [cmd_copy, 'DEST-FILE [GLOB-PATTERN]'], - 'to-pixie' : [cmd_topixie, '[TAG [PIXIE-TAG]]'], - 'delete' : [cmd_del, 'TAG'], - 'dump' : [cmd_dump, '']} + '[-c CIPHER] [-h HASH] [-m MAC] [PP-TAG]'], + 'find' : [cmd_find, 'LABEL'], + 'store' : [cmd_store, 'LABEL [VALUE]'], + 'list' : [cmd_list, '[GLOB-PATTERN]'], + 'changepp' : [cmd_changepp, ''], + 'copy' : [cmd_copy, 'DEST-FILE [GLOB-PATTERN]'], + 'to-pixie' : [cmd_topixie, '[TAG [PIXIE-TAG]]'], + 'delete' : [cmd_del, 'TAG'], + 'dump' : [cmd_dump, '']} + +###-------------------------------------------------------------------------- +### Command-line handling and dispatch. def version(): print '%s 1.0.0' % prog + def usage(fp): print >>fp, 'Usage: %s COMMAND [ARGS...]' % prog + def help(): version() print @@ -200,10 +256,16 @@ Commands provided: for c in commands: print '%s %s' % (c, commands[c][1]) +## Choose a default database file. +if 'PWSAFE' in environ: + file = environ['PWSAFE'] +else: + file = '%s/.pwsafe' % environ['HOME'] + +## Parse the command-line options. try: - opts, argv = getopt(argv[1:], - 'hvuf:', - ['help', 'version', 'usage', 'file=']) + opts, argv = getopt(argv[1:], 'hvuf:', + ['help', 'version', 'usage', 'file=']) except GetoptError: usage(stderr) exit(1) @@ -225,6 +287,7 @@ if len(argv) < 1: usage(stderr) exit(1) +## Dispatch to a command handler. if argv[0] in commands: c = argv[0] argv = argv[1:] @@ -233,3 +296,5 @@ else: if commands[c][0](argv): print >>stderr, 'Usage: %s %s %s' % (prog, c, commands[c][1]) exit(1) + +###----- That's all, folks -------------------------------------------------- -- 2.11.0