Commit | Line | Data |
---|---|---|
d1c45f5c MW |
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. | |
43c09851 | 28 | |
494b719c MW |
29 | from __future__ import with_statement |
30 | ||
43c09851 | 31 | import catacomb as _C |
32 | import gdbm as _G | |
d1c45f5c MW |
33 | |
34 | ###-------------------------------------------------------------------------- | |
35 | ### Underlying cryptography. | |
36 | ||
43c09851 | 37 | class DecryptError (Exception): |
d1c45f5c MW |
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 | """ | |
43c09851 | 44 | pass |
45 | ||
46 | class Crypto (object): | |
d1c45f5c MW |
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 | ||
43c09851 | 60 | def __init__(me, c, h, m, ck, mk): |
d1c45f5c MW |
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 | """ | |
43c09851 | 67 | me.c = c(ck) |
68 | me.m = m(mk) | |
69 | me.h = h | |
d1c45f5c | 70 | |
43c09851 | 71 | def encrypt(me, pt): |
d1c45f5c MW |
72 | """ |
73 | Encrypt the message PT and return the resulting ciphertext. | |
74 | """ | |
9a7b948f MW |
75 | blksz = me.c.__class__.blksz |
76 | b = _C.WriteBuffer() | |
77 | if blksz: | |
78 | iv = _C.rand.block(blksz) | |
43c09851 | 79 | me.c.setiv(iv) |
9a7b948f MW |
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 | ||
43c09851 | 85 | def decrypt(me, ct): |
d1c45f5c MW |
86 | """ |
87 | Decrypt the ciphertext CT, returning the plaintext. | |
88 | ||
89 | Raises DecryptError if anything goes wrong. | |
90 | """ | |
9a7b948f MW |
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) | |
b2687a0a | 104 | |
43c09851 | 105 | class PPK (Crypto): |
d1c45f5c MW |
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 | ||
43c09851 | 113 | def __init__(me, pp, c, h, m, salt = None): |
d1c45f5c MW |
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 | """ | |
43c09851 | 121 | if not salt: salt = _C.rand.block(h.hashsz) |
122 | tag = '%s\0%s' % (pp, salt) | |
123 | Crypto.__init__(me, c, h, m, | |
d1c45f5c MW |
124 | h().hash('cipher:' + tag).done(), |
125 | h().hash('mac:' + tag).done()) | |
43c09851 | 126 | me.salt = salt |
127 | ||
d1c45f5c | 128 | ###-------------------------------------------------------------------------- |
494b719c MW |
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 | ###-------------------------------------------------------------------------- | |
d1c45f5c | 282 | ### Password storage. |
43c09851 | 283 | |
43c09851 | 284 | class PW (object): |
d1c45f5c MW |
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 | ||
2119e334 | 291 | I implement (some of) the Python mapping protocol. |
d1c45f5c | 292 | |
494b719c MW |
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. | |
d1c45f5c MW |
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 | ||
494b719c MW |
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. | |
d1c45f5c MW |
317 | """ |
318 | ||
4a35c9a7 | 319 | def __init__(me, file, writep = False): |
d1c45f5c | 320 | """ |
494b719c | 321 | Initialize a PW object from the database in FILE. |
d1c45f5c | 322 | |
494b719c MW |
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. | |
d1c45f5c MW |
326 | """ |
327 | ||
328 | ## Open the database. | |
494b719c | 329 | me.db = StorageBackend(file, writep) |
d1c45f5c MW |
330 | |
331 | ## Find out what crypto to use. | |
494b719c MW |
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')] | |
d1c45f5c MW |
335 | |
336 | ## Request the passphrase and extract the master keys. | |
494b719c MW |
337 | tag = me.db.get_meta('tag') |
338 | ppk = PPK(_C.ppread(tag), c, h, m, me.db.get_meta('salt')) | |
43c09851 | 339 | try: |
494b719c | 340 | b = _C.ReadBuffer(ppk.decrypt(me.db.get_meta('key'))) |
43c09851 | 341 | except DecryptError: |
342 | _C.ppcancel(tag) | |
343 | raise | |
9a7b948f MW |
344 | me.ck = b.getblk16() |
345 | me.mk = b.getblk16() | |
346 | if not b.endp: raise ValueError, 'trailing junk' | |
d1c45f5c MW |
347 | |
348 | ## Set the key, and stash it and the tag-hashing secret. | |
43c09851 | 349 | me.k = Crypto(c, h, m, me.ck, me.mk) |
494b719c | 350 | me.magic = me.k.decrypt(me.db.get_meta('magic')) |
d1c45f5c | 351 | |
09b8041d | 352 | @classmethod |
494b719c | 353 | def create(cls, file, tag, c, h, m): |
09b8041d | 354 | """ |
494b719c | 355 | Create and initialize a new database FILE. |
09b8041d MW |
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. | |
494b719c MW |
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))) | |
09b8041d | 381 | |
43c09851 | 382 | def keyxform(me, key): |
494b719c MW |
383 | """Transform the KEY (actually a password tag) into a password label.""" |
384 | return me.k.h().hash(me.magic).hash(key).done() | |
d1c45f5c | 385 | |
43c09851 | 386 | def changepp(me): |
d1c45f5c MW |
387 | """ |
388 | Change the database password. | |
389 | ||
390 | Requests the new password from the Pixie, which will probably cause | |
391 | interaction. | |
392 | """ | |
494b719c | 393 | tag = me.db.get_meta('tag') |
43c09851 | 394 | _C.ppcancel(tag) |
395 | ppk = PPK(_C.ppread(tag, _C.PMODE_VERIFY), | |
d1c45f5c | 396 | me.k.c.__class__, me.k.h, me.k.m.__class__) |
494b719c MW |
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) | |
d1c45f5c | 400 | |
43c09851 | 401 | def pack(me, key, value): |
494b719c | 402 | """Pack the KEY and VALUE into a ciphertext, and return it.""" |
9a7b948f MW |
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) | |
d1c45f5c MW |
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 | """ | |
9a7b948f MW |
414 | b = _C.ReadBuffer(me.k.decrypt(ct)) |
415 | key = b.getblk16() | |
416 | value = b.getblk16() | |
43c09851 | 417 | return key, value |
d1c45f5c MW |
418 | |
419 | ## Mapping protocol. | |
420 | ||
43c09851 | 421 | def __getitem__(me, key): |
494b719c MW |
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 | |
d1c45f5c | 425 | |
43c09851 | 426 | def __setitem__(me, key, value): |
494b719c MW |
427 | """Associate the password VALUE with the KEY.""" |
428 | me.db.put_passwd(me.keyxform(key), me.pack(key, value)) | |
d1c45f5c | 429 | |
43c09851 | 430 | def __delitem__(me, key): |
494b719c MW |
431 | """Forget all about the KEY.""" |
432 | try: me.db.del_passwd(me.keyxform(key)) | |
433 | except KeyError: raise KeyError, key | |
d1c45f5c | 434 | |
43c09851 | 435 | def __iter__(me): |
494b719c MW |
436 | """Iterate over the known password tags.""" |
437 | for _, pld in me.db.iter_passwds(): | |
438 | yield me.unpack(pld)[0] | |
43c09851 | 439 | |
5bf6e9f5 MW |
440 | ## Context protocol. |
441 | ||
442 | def __enter__(me): | |
443 | return me | |
444 | def __exit__(me, excty, excval, exctb): | |
445 | me.db.close() | |
446 | ||
d1c45f5c | 447 | ###----- That's all, folks -------------------------------------------------- |