chiark / gitweb /
catacomb/pwsafe.py: Factor database handling out into a StorageBackend.
[catacomb-python] / catacomb / pwsafe.py
1 ### -*-python-*-
2 ###
3 ### Management of a secure password database
4 ###
5 ### (c) 2005 Straylight/Edgeware
6 ###
7
8 ###----- Licensing notice ---------------------------------------------------
9 ###
10 ### This file is part of the Python interface to Catacomb.
11 ###
12 ### Catacomb/Python is free software; you can redistribute it and/or modify
13 ### it under the terms of the GNU General Public License as published by
14 ### the Free Software Foundation; either version 2 of the License, or
15 ### (at your option) any later version.
16 ###
17 ### Catacomb/Python is distributed in the hope that it will be useful,
18 ### but WITHOUT ANY WARRANTY; without even the implied warranty of
19 ### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
20 ### GNU General Public License for more details.
21 ###
22 ### You should have received a copy of the GNU General Public License along
23 ### with Catacomb/Python; if not, write to the Free Software Foundation,
24 ### Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
25
26 ###--------------------------------------------------------------------------
27 ### Imported modules.
28
29 from __future__ import with_statement
30
31 import catacomb as _C
32 import gdbm as _G
33
34 ###--------------------------------------------------------------------------
35 ### Underlying cryptography.
36
37 class DecryptError (Exception):
38   """
39   I represent a failure to decrypt a message.
40
41   Usually this means that someone used the wrong key, though it can also
42   mean that a ciphertext has been modified.
43   """
44   pass
45
46 class Crypto (object):
47   """
48   I represent a symmetric crypto transform.
49
50   There's currently only one transform implemented, which is the obvious
51   generic-composition construction: given a message m, and keys K0 and K1, we
52   choose an IV v, and compute:
53
54     * y = v || E(K0, v; m)
55     * t = M(K1; y)
56
57   The final ciphertext is t || y.
58   """
59
60   def __init__(me, c, h, m, ck, mk):
61     """
62     Initialize the Crypto object with a given algorithm selection and keys.
63
64     We need a GCipher subclass C, a GHash subclass H, a GMAC subclass M, and
65     keys CK and MK for C and M respectively.
66     """
67     me.c = c(ck)
68     me.m = m(mk)
69     me.h = h
70
71   def encrypt(me, pt):
72     """
73     Encrypt the message PT and return the resulting ciphertext.
74     """
75     blksz = me.c.__class__.blksz
76     b = _C.WriteBuffer()
77     if blksz:
78       iv = _C.rand.block(blksz)
79       me.c.setiv(iv)
80       b.put(iv)
81     b.put(me.c.encrypt(pt))
82     t = me.m().hash(b).done()
83     return t + str(buffer(b))
84
85   def decrypt(me, ct):
86     """
87     Decrypt the ciphertext CT, returning the plaintext.
88
89     Raises DecryptError if anything goes wrong.
90     """
91     blksz = me.c.__class__.blksz
92     tagsz = me.m.__class__.tagsz
93     b = _C.ReadBuffer(ct)
94     t = b.get(tagsz)
95     h = me.m()
96     if blksz:
97       iv = b.get(blksz)
98       me.c.setiv(iv)
99       h.hash(iv)
100     x = b.get(b.left)
101     h.hash(x)
102     if t != h.done(): raise DecryptError
103     return me.c.decrypt(x)
104
105 class PPK (Crypto):
106   """
107   I represent a crypto transform whose keys are derived from a passphrase.
108
109   The password is salted and hashed; the salt is available as the `salt'
110   attribute.
111   """
112
113   def __init__(me, pp, c, h, m, salt = None):
114     """
115     Initialize the PPK object with a passphrase and algorithm selection.
116
117     We want a passphrase PP, a GCipher subclass C, a GHash subclass H, a GMAC
118     subclass M, and a SALT.  The SALT may be None, if we're generating new
119     keys, indicating that a salt should be chosen randomly.
120     """
121     if not salt: salt = _C.rand.block(h.hashsz)
122     tag = '%s\0%s' % (pp, salt)
123     Crypto.__init__(me, c, h, m,
124                     h().hash('cipher:' + tag).done(),
125                     h().hash('mac:' + tag).done())
126     me.salt = salt
127
128 ###--------------------------------------------------------------------------
129 ### Backend storage.
130
131 class StorageBackend (object):
132   """
133   I provide a backend for password and metadata storage.
134
135   Backends are responsible for storing and retrieving stuff, but not for the
136   cryptographic details.  Backends need to store two kinds of information:
137
138     * metadata, consisting of a number of property names and their values;
139       and
140
141     * password mappings, consisting of a number of binary labels and
142       payloads.
143   """
144
145   FAIL = ['FAIL']
146
147   ## Life cycle methods.
148
149   @classmethod
150   def create(cls, file):
151     """Create a new database in the named FILE, using this backend."""
152     return cls(writep = True, _magic = lambda me: me._create(file))
153   def _create(me, file):
154     me._db = _G.open(file, 'n', 0600)
155
156   def __init__(me, file = None, writep = False, _magic = None, *args, **kw):
157     """
158     Main constructor.
159     """
160     super(StorageBackend, me).__init__(*args, **kw)
161     if _magic is not None: _magic(me)
162     elif file is None: raise ValueError, 'missing file parameter'
163     else: me._db = _G.open(file, writep and 'w' or 'r')
164     me._writep = writep
165     me._livep = True
166
167   def close(me):
168     """
169     Close the database.
170
171     It is harmless to attempt to close a database which has been closed
172     already.
173     """
174     if me._livep:
175       me._livep = False
176       me._db.close()
177
178   ## Utilities.
179
180   def _check_live(me):
181     """Raise an error if the receiver has been closed."""
182     if not me._livep: raise ValueError, 'database is closed'
183
184   def _check_write(me):
185     """Raise an error if the receiver is not open for writing."""
186     me._check_live()
187     if not me._writep: raise ValueError, 'database is read-only'
188
189   def _check_meta_name(me, name):
190     """
191     Raise an error unless NAME is a valid name for a metadata item.
192
193     Metadata names may not start with `$': such names are reserved for
194     password storage.
195     """
196     if name.startswith('$'):
197       raise ValueError, "invalid metadata key `%s'" % name
198
199   ## Context protocol.
200
201   def __enter__(me):
202     """Context protocol: make sure the database is closed on exit."""
203     return me
204   def __exit__(me, exctype, excvalue, exctb):
205     """Context protocol: see `__enter__'."""
206     me.close()
207
208   ## Metadata.
209
210   def get_meta(me, name, default = FAIL):
211     """
212     Fetch the value for the metadata item NAME.
213
214     If no such item exists, then return DEFAULT if that was set; otherwise
215     raise a `KeyError'.
216     """
217     me._check_meta_name(name)
218     me._check_live()
219     try: value = me._db[name]
220     except KeyError: value = default
221     if value is StorageBackend.FAIL: raise KeyError, name
222     return value
223
224   def put_meta(me, name, value):
225     """Store VALUE in the metadata item called NAME."""
226     me._check_meta_name(name)
227     me._check_write()
228     me._db[name] = value
229
230   def del_meta(me, name):
231     """Forget about the metadata item with the given NAME."""
232     me._check_meta_name(name)
233     me._check_write()
234     del me._db[name]
235
236   def iter_meta(me):
237     """Return an iterator over the name/value metadata items."""
238     me._check_live()
239     k = me._db.firstkey()
240     while k is not None:
241       if not k.startswith('$'): yield k, me._db[k]
242       k = me._db.nextkey(k)
243
244   ## Passwords.
245
246   def get_passwd(me, label):
247     """
248     Fetch and return the payload stored with the (opaque, binary) LABEL.
249
250     If there is no such payload then raise `KeyError'.
251     """
252     me._check_live()
253     return me._db['$' + label]
254
255   def put_passwd(me, label, payload):
256     """
257     Associate the (opaque, binary) PAYLOAD with the (opaque, binary) LABEL.
258
259     Any previous payload for LABEL is forgotten.
260     """
261     me._check_write()
262     me._db['$' + label] = payload
263
264   def del_passwd(me, label):
265     """
266     Forget any PAYLOAD associated with the (opaque, binary) LABEL.
267
268     If there is no such payload then raise `KeyError'.
269     """
270     me._check_write()
271     del me._db['$' + label]
272
273   def iter_passwds(me):
274     """Return an iterator over the stored password label/payload pairs."""
275     me._check_live()
276     k = me._db.firstkey()
277     while k is not None:
278       if k.startswith('$'): yield k[1:], me._db[k]
279       k = me._db.nextkey(k)
280
281 ###--------------------------------------------------------------------------
282 ### Password storage.
283
284 class PW (object):
285   """
286   I represent a secure (ish) password store.
287
288   I can store short secrets, associated with textual names, in a way which
289   doesn't leak too much information about them.
290
291   I implement (some of) the Python mapping protocol.
292
293   I keep track of everything using a StorageBackend object.  This contains
294   password entries, identified by cryptographic labels, and a number of
295   metadata items.
296
297   cipher        Names the Catacomb cipher selected.
298
299   hash          Names the Catacomb hash function selected.
300
301   key           Cipher and MAC keys, each prefixed by a 16-bit big-endian
302                 length and concatenated, encrypted using the master
303                 passphrase.
304
305   mac           Names the Catacomb message authentication code selected.
306
307   magic         A magic string for obscuring password tag names.
308
309   salt          The salt for hashing the passphrase.
310
311   tag           The master passphrase's tag, for the Pixie's benefit.
312
313   Password entries are assigned labels of the form `$' || H(MAGIC || TAG);
314   the corresponding value consists of a pair (TAG, PASSWD), prefixed with
315   16-bit lengths, concatenated, padded to a multiple of 256 octets, and
316   encrypted using the stored keys.
317   """
318
319   def __init__(me, file, writep = False):
320     """
321     Initialize a PW object from the database in FILE.
322
323     If WRITEP is false (the default) then the database is opened read-only;
324     if true then it may be written.  Requests the database password from the
325     Pixie, which may cause interaction.
326     """
327
328     ## Open the database.
329     me.db = StorageBackend(file, writep)
330
331     ## Find out what crypto to use.
332     c = _C.gcciphers[me.db.get_meta('cipher')]
333     h = _C.gchashes[me.db.get_meta('hash')]
334     m = _C.gcmacs[me.db.get_meta('mac')]
335
336     ## Request the passphrase and extract the master keys.
337     tag = me.db.get_meta('tag')
338     ppk = PPK(_C.ppread(tag), c, h, m, me.db.get_meta('salt'))
339     try:
340       b = _C.ReadBuffer(ppk.decrypt(me.db.get_meta('key')))
341     except DecryptError:
342       _C.ppcancel(tag)
343       raise
344     me.ck = b.getblk16()
345     me.mk = b.getblk16()
346     if not b.endp: raise ValueError, 'trailing junk'
347
348     ## Set the key, and stash it and the tag-hashing secret.
349     me.k = Crypto(c, h, m, me.ck, me.mk)
350     me.magic = me.k.decrypt(me.db.get_meta('magic'))
351
352   @classmethod
353   def create(cls, file, tag, c, h, m):
354     """
355     Create and initialize a new database FILE.
356
357     We want a GCipher subclass C, a GHash subclass H, and a GMAC subclass M;
358     and a Pixie passphrase TAG.
359
360     This doesn't return a working object: it just creates the database file
361     and gets out of the way.
362     """
363
364     ## Set up the cryptography.
365     pp = _C.ppread(tag, _C.PMODE_VERIFY)
366     ppk = PPK(pp, c, h, m)
367     ck = _C.rand.block(c.keysz.default)
368     mk = _C.rand.block(c.keysz.default)
369     k = Crypto(c, h, m, ck, mk)
370
371     ## Set up and initialize the database.
372     kct = ppk.encrypt(_C.WriteBuffer().putblk16(ck).putblk16(mk))
373     with StorageBackend.create(file) as db:
374       db.put_meta('tag', tag)
375       db.put_meta('salt', ppk.salt)
376       db.put_meta('cipher', c.name)
377       db.put_meta('hash', h.name)
378       db.put_meta('mac', m.name)
379       db.put_meta('key', kct)
380       db.put_meta('magic', k.encrypt(_C.rand.block(h.hashsz)))
381
382   def keyxform(me, key):
383     """Transform the KEY (actually a password tag) into a password label."""
384     return me.k.h().hash(me.magic).hash(key).done()
385
386   def changepp(me):
387     """
388     Change the database password.
389
390     Requests the new password from the Pixie, which will probably cause
391     interaction.
392     """
393     tag = me.db.get_meta('tag')
394     _C.ppcancel(tag)
395     ppk = PPK(_C.ppread(tag, _C.PMODE_VERIFY),
396               me.k.c.__class__, me.k.h, me.k.m.__class__)
397     kct = ppk.encrypt(_C.WriteBuffer().putblk16(me.ck).putblk16(me.mk))
398     me.db.put_meta('key', kct)
399     me.db.put_meta('salt', ppk.salt)
400
401   def pack(me, key, value):
402     """Pack the KEY and VALUE into a ciphertext, and return it."""
403     b = _C.WriteBuffer()
404     b.putblk16(key).putblk16(value)
405     b.zero(((b.size + 255) & ~255) - b.size)
406     return me.k.encrypt(b)
407
408   def unpack(me, ct):
409     """
410     Unpack a ciphertext CT and return a (KEY, VALUE) pair.
411
412     Might raise DecryptError, of course.
413     """
414     b = _C.ReadBuffer(me.k.decrypt(ct))
415     key = b.getblk16()
416     value = b.getblk16()
417     return key, value
418
419   ## Mapping protocol.
420
421   def __getitem__(me, key):
422     """Return the password for the given KEY."""
423     try: return me.unpack(me.db.get_passwd(me.keyxform(key)))[1]
424     except KeyError: raise KeyError, key
425
426   def __setitem__(me, key, value):
427     """Associate the password VALUE with the KEY."""
428     me.db.put_passwd(me.keyxform(key), me.pack(key, value))
429
430   def __delitem__(me, key):
431     """Forget all about the KEY."""
432     try: me.db.del_passwd(me.keyxform(key))
433     except KeyError: raise KeyError, key
434
435   def __iter__(me):
436     """Iterate over the known password tags."""
437     for _, pld in me.db.iter_passwds():
438       yield me.unpack(pld)[0]
439
440   ## Context protocol.
441
442   def __enter__(me):
443     return me
444   def __exit__(me, excty, excval, exctb):
445     me.db.close()
446
447 ###----- That's all, folks --------------------------------------------------