catacomb/pwsafe.py: Use `binascii' for Base64 conversion.
[catacomb-python] / catacomb / pwsafe.py
CommitLineData
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
29from __future__ import with_statement
30
3c24c1aa 31import binascii as _B
af861fb7 32import errno as _E
6baae405 33import os as _OS
af861fb7 34from cStringIO import StringIO as _StringIO
6baae405 35
43c09851 36import catacomb as _C
d1c45f5c
MW
37
38###--------------------------------------------------------------------------
f6d012db
MW
39### Python version portability.
40
d6542364
MW
41def _iterkeys(dict): return dict.iterkeys()
42def _itervalues(dict): return dict.itervalues()
43def _iteritems(dict): return dict.iteritems()
44
467c2619
MW
45def _bin(text): return text
46def _text(bin): return bin
47
48_NUL = _bin('\0')
49_CIPHER = _bin('cipher:')
50_MAC = _bin('mac:')
51
0e0e6b09
MW
52def _with_metaclass(meta, *supers):
53 return meta("#<anonymous base %s>" % meta.__name__,
54 supers or (object,), dict())
55
f6d012db
MW
56def _excval(): return _SYS.exc_info()[1]
57
9ef0b2c9
MW
58_M600 = int("600", 8)
59_M700 = int("700", 8)
60
f6d012db 61###--------------------------------------------------------------------------
af861fb7
MW
62### Text encoding utilities.
63
64def _literalp(s):
65 """
66 Answer whether S can be represented literally.
67
68 If True, then S can be stored literally, as a metadata item name or
69 value; if False, then S requires some kind of encoding.
70 """
71 return all(ch.isalnum() or ch in '-_:' for ch in s)
72
73def _enc_metaname(name):
74 """Encode NAME as a metadata item name, returning the result."""
75 if _literalp(name):
76 return name
77 else:
78 sio = _StringIO()
79 sio.write('!')
80 for ch in name:
81 if _literalp(ch): sio.write(ch)
82 elif ch == ' ': sio.write('+')
83 else: sio.write('%%%02x' % ord(ch))
84 return sio.getvalue()
85
86def _dec_metaname(name):
87 """Decode NAME as a metadata item name, returning the result."""
88 if not name.startswith('!'):
89 return name
90 else:
91 sio = _StringIO()
92 i, n = 1, len(name)
93 while i < n:
94 ch = name[i]
95 i += 1
96 if ch == '+':
97 sio.write(' ')
98 elif ch == '%':
99 sio.write(chr(int(name[i:i + 2], 16)))
100 i += 2
101 else:
102 sio.write(ch)
103 return sio.getvalue()
104
105def _b64(s):
106 """Encode S as base64, without newlines, and trimming `=' padding."""
3c24c1aa 107 return _text(_B.b2a_base64(s)).replace('\n', '').rstrip('=')
af861fb7
MW
108def _unb64(s):
109 """Decode S as base64 with trimmed `=' padding."""
3c24c1aa 110 return _B.a2b_base64(s + '='*((4 - len(s))%4))
af861fb7
MW
111
112def _enc_metaval(val):
113 """Encode VAL as a metadata item value, returning the result."""
114 if _literalp(val): return val
115 else: return '?' + _b64(val)
116
117def _dec_metaval(val):
118 """Decode VAL as a metadata item value, returning the result."""
119 if not val.startswith('?'): return val
120 else: return _unb64(val[1:])
121
122###--------------------------------------------------------------------------
d1c45f5c
MW
123### Underlying cryptography.
124
43c09851 125class DecryptError (Exception):
d1c45f5c
MW
126 """
127 I represent a failure to decrypt a message.
128
129 Usually this means that someone used the wrong key, though it can also
130 mean that a ciphertext has been modified.
131 """
43c09851 132 pass
133
134class Crypto (object):
d1c45f5c
MW
135 """
136 I represent a symmetric crypto transform.
137
138 There's currently only one transform implemented, which is the obvious
139 generic-composition construction: given a message m, and keys K0 and K1, we
140 choose an IV v, and compute:
141
142 * y = v || E(K0, v; m)
143 * t = M(K1; y)
144
145 The final ciphertext is t || y.
146 """
147
43c09851 148 def __init__(me, c, h, m, ck, mk):
d1c45f5c
MW
149 """
150 Initialize the Crypto object with a given algorithm selection and keys.
151
152 We need a GCipher subclass C, a GHash subclass H, a GMAC subclass M, and
153 keys CK and MK for C and M respectively.
154 """
43c09851 155 me.c = c(ck)
156 me.m = m(mk)
157 me.h = h
d1c45f5c 158
43c09851 159 def encrypt(me, pt):
d1c45f5c
MW
160 """
161 Encrypt the message PT and return the resulting ciphertext.
162 """
9a7b948f
MW
163 blksz = me.c.__class__.blksz
164 b = _C.WriteBuffer()
165 if blksz:
166 iv = _C.rand.block(blksz)
43c09851 167 me.c.setiv(iv)
9a7b948f
MW
168 b.put(iv)
169 b.put(me.c.encrypt(pt))
170 t = me.m().hash(b).done()
171 return t + str(buffer(b))
172
43c09851 173 def decrypt(me, ct):
d1c45f5c
MW
174 """
175 Decrypt the ciphertext CT, returning the plaintext.
176
177 Raises DecryptError if anything goes wrong.
178 """
9a7b948f
MW
179 blksz = me.c.__class__.blksz
180 tagsz = me.m.__class__.tagsz
181 b = _C.ReadBuffer(ct)
182 t = b.get(tagsz)
183 h = me.m()
184 if blksz:
185 iv = b.get(blksz)
186 me.c.setiv(iv)
187 h.hash(iv)
188 x = b.get(b.left)
189 h.hash(x)
190 if t != h.done(): raise DecryptError
191 return me.c.decrypt(x)
b2687a0a 192
43c09851 193class PPK (Crypto):
d1c45f5c
MW
194 """
195 I represent a crypto transform whose keys are derived from a passphrase.
196
197 The password is salted and hashed; the salt is available as the `salt'
198 attribute.
199 """
200
43c09851 201 def __init__(me, pp, c, h, m, salt = None):
d1c45f5c
MW
202 """
203 Initialize the PPK object with a passphrase and algorithm selection.
204
205 We want a passphrase PP, a GCipher subclass C, a GHash subclass H, a GMAC
206 subclass M, and a SALT. The SALT may be None, if we're generating new
207 keys, indicating that a salt should be chosen randomly.
208 """
43c09851 209 if not salt: salt = _C.rand.block(h.hashsz)
467c2619 210 tag = pp + _NUL + salt
43c09851 211 Crypto.__init__(me, c, h, m,
467c2619
MW
212 h().hash(_CIPHER).hash(tag).done(),
213 h().hash(_MAC).hash(tag).done())
43c09851 214 me.salt = salt
215
d1c45f5c 216###--------------------------------------------------------------------------
494b719c
MW
217### Backend storage.
218
6baae405
MW
219class StorageBackendRefusal (Exception):
220 """
221 I signify that a StorageBackend subclass has refused to open a file.
222
223 This is used by the StorageBackend.open class method.
224 """
225 pass
226
227class StorageBackendClass (type):
228 """
229 I am a metaclass for StorageBackend classes.
230
231 My main feature is that I register my concrete instances (with a `NAME'
232 which is not `None') with the StorageBackend class.
233 """
234 def __init__(me, name, supers, dict):
235 """
236 Register a new concrete StorageBackend subclass.
237 """
238 super(StorageBackendClass, me).__init__(name, supers, dict)
94db70d4
MW
239 try: name = me.NAME
240 except AttributeError: pass
241 else: StorageBackend.register_concrete_subclass(me)
6baae405 242
0e0e6b09 243class StorageBackend (_with_metaclass(StorageBackendClass)):
494b719c 244 """
1726ab40
MW
245 I provide basic protocol for password storage backends.
246
247 I'm an abstract class: you want one of my subclasses if you actually want
6baae405
MW
248 to do something useful. But I maintain a list of my subclasses and can
249 choose an appropriate one to open a database file you've found lying about.
494b719c
MW
250
251 Backends are responsible for storing and retrieving stuff, but not for the
252 cryptographic details. Backends need to store two kinds of information:
253
254 * metadata, consisting of a number of property names and their values;
255 and
256
257 * password mappings, consisting of a number of binary labels and
258 payloads.
1726ab40
MW
259
260 Backends need to implement the following ordinary methods. See the calling
261 methods for details of the subclass responsibilities.
262
263 BE._create(FILE) Create a new database in FILE; used by `create'.
264
265 BE._open(FILE, WRITEP)
266 Open the existing database FILE; used by `open'.
267
053c2659
MW
268 BE._close(ABRUPTP) Close the database, freeing up any resources. If
269 ABRUPTP then don't try to commit changes.
1726ab40
MW
270
271 BE._get_meta(NAME, DEFAULT)
272 Return the value of the metadata item with the given
273 NAME, or DEFAULT if it doesn't exist; used by
274 `get_meta'.
275
276 BE._put_meta(NAME, VALUE)
277 Set the VALUE of the metadata item with the given
278 NAME, creating one if necessary; used by `put_meta'.
279
280 BE._del_meta(NAME) Forget the metadata item with the given NAME; raise
281 `KeyError' if there is no such item; used by
282 `del_meta'.
283
284 BE._iter_meta() Return an iterator over the metadata (NAME, VALUE)
285 pairs; used by `iter_meta'.
286
287 BE._get_passwd(LABEL)
288 Return the password payload stored with the (binary)
289 LABEL; used by `get_passwd'.
290
291 BE._put_passwd(LABEL, PAYLOAD)
292 Associate the (binary) PAYLOAD with the LABEL,
293 forgetting any previous payload for that LABEL; used
294 by `put_passwd'.
295
296 BE._del_passwd(LABEL) Forget the password record with the given LABEL; used
297 by `_del_passwd'.
298
299 BE._iter_passwds() Return an iterator over the password (LABEL, PAYLOAD)
300 pairs; used by `iter_passwds'.
6baae405
MW
301
302 Also, concrete subclasses should define the following class attributes.
303
304 NAME The name of the backend, so that the user can select
305 it when creating a new database.
306
307 PRIO An integer priority: backends are tried in decreasing
308 priority order when opening an existing database.
494b719c
MW
309 """
310
6baae405
MW
311 PRIO = 10
312
313 ## The registry of subclasses.
314 CLASSES = {}
315
494b719c
MW
316 FAIL = ['FAIL']
317
6baae405
MW
318 @staticmethod
319 def register_concrete_subclass(sub):
320 """Register a concrete subclass, so that `open' can try it."""
321 StorageBackend.CLASSES[sub.NAME] = sub
322
323 @staticmethod
324 def byname(name):
325 """
326 Return the concrete subclass with the given NAME.
327
328 Raise `KeyError' if the name isn't found.
329 """
330 return StorageBackend.CLASSES[name]
331
332 @staticmethod
333 def classes():
334 """Return an iterator over the concrete subclasses."""
d6542364 335 return _itervalues(StorageBackend.CLASSES)
6baae405
MW
336
337 @staticmethod
338 def open(file, writep = False):
339 """Open a database FILE, using some appropriate backend."""
340 _OS.stat(file)
341 for cls in sorted(StorageBackend.CLASSES.values(), reverse = True,
342 key = lambda cls: cls.PRIO):
343 try: return cls(file, writep)
344 except StorageBackendRefusal: pass
345 raise StorageBackendRefusal
494b719c
MW
346
347 @classmethod
348 def create(cls, file):
1726ab40
MW
349 """
350 Create a new database in the named FILE, using this backend.
351
352 Subclasses must implement the `_create' instance method.
353 """
494b719c 354 return cls(writep = True, _magic = lambda me: me._create(file))
494b719c
MW
355
356 def __init__(me, file = None, writep = False, _magic = None, *args, **kw):
357 """
358 Main constructor.
1726ab40
MW
359
360 Subclasses are not, in general, expected to override this: there's a
361 somewhat hairy protocol between the constructor and some of the class
362 methods. Instead, the main hook for customization is the subclass's
363 `_open' method, which is invoked in the usual case.
494b719c
MW
364 """
365 super(StorageBackend, me).__init__(*args, **kw)
1c7419c9 366 if me.NAME is None: raise ValueError('abstract class')
494b719c 367 if _magic is not None: _magic(me)
1c7419c9 368 elif file is None: raise ValueError('missing file parameter')
1726ab40 369 else: me._open(file, writep)
494b719c
MW
370 me._writep = writep
371 me._livep = True
372
053c2659 373 def close(me, abruptp = False):
494b719c
MW
374 """
375 Close the database.
376
377 It is harmless to attempt to close a database which has been closed
1726ab40 378 already. Calls the subclass's `_close' method.
494b719c
MW
379 """
380 if me._livep:
381 me._livep = False
053c2659 382 me._close(abruptp)
494b719c
MW
383
384 ## Utilities.
385
386 def _check_live(me):
387 """Raise an error if the receiver has been closed."""
1c7419c9 388 if not me._livep: raise ValueError('database is closed')
494b719c
MW
389
390 def _check_write(me):
391 """Raise an error if the receiver is not open for writing."""
392 me._check_live()
1c7419c9 393 if not me._writep: raise ValueError('database is read-only')
494b719c
MW
394
395 def _check_meta_name(me, name):
396 """
397 Raise an error unless NAME is a valid name for a metadata item.
398
399 Metadata names may not start with `$': such names are reserved for
400 password storage.
401 """
402 if name.startswith('$'):
1c7419c9 403 raise ValueError("invalid metadata key `%s'" % name)
494b719c
MW
404
405 ## Context protocol.
406
407 def __enter__(me):
408 """Context protocol: make sure the database is closed on exit."""
409 return me
410 def __exit__(me, exctype, excvalue, exctb):
411 """Context protocol: see `__enter__'."""
053c2659 412 me.close(excvalue is not None)
494b719c
MW
413
414 ## Metadata.
415
416 def get_meta(me, name, default = FAIL):
417 """
418 Fetch the value for the metadata item NAME.
419
420 If no such item exists, then return DEFAULT if that was set; otherwise
421 raise a `KeyError'.
1726ab40
MW
422
423 This calls the subclass's `_get_meta' method, which should return the
424 requested item or return the given DEFAULT value. It may assume that the
425 name is valid and the database is open.
494b719c
MW
426 """
427 me._check_meta_name(name)
428 me._check_live()
1726ab40 429 value = me._get_meta(name, default)
1c7419c9 430 if value is StorageBackend.FAIL: raise KeyError(name)
494b719c
MW
431 return value
432
433 def put_meta(me, name, value):
1726ab40
MW
434 """
435 Store VALUE in the metadata item called NAME.
436
437 This calls the subclass's `_put_meta' method, which may assume that the
438 name is valid and the database is open for writing.
439 """
494b719c
MW
440 me._check_meta_name(name)
441 me._check_write()
1726ab40 442 me._put_meta(name, value)
494b719c
MW
443
444 def del_meta(me, name):
1726ab40
MW
445 """
446 Forget about the metadata item with the given NAME.
447
448 This calls the subclass's `_del_meta' method, which may assume that the
449 name is valid and the database is open for writing.
450 """
494b719c
MW
451 me._check_meta_name(name)
452 me._check_write()
1726ab40 453 me._del_meta(name)
494b719c
MW
454
455 def iter_meta(me):
1726ab40
MW
456 """
457 Return an iterator over the name/value metadata items.
494b719c 458
1726ab40
MW
459 This calls the subclass's `_iter_meta' method, which may assume that the
460 database is open.
461 """
462 me._check_live()
463 return me._iter_meta()
494b719c
MW
464
465 def get_passwd(me, label):
466 """
467 Fetch and return the payload stored with the (opaque, binary) LABEL.
468
469 If there is no such payload then raise `KeyError'.
1726ab40
MW
470
471 This calls the subclass's `_get_passwd' method, which may assume that the
472 database is open.
494b719c
MW
473 """
474 me._check_live()
1726ab40 475 return me._get_passwd(label)
494b719c
MW
476
477 def put_passwd(me, label, payload):
478 """
479 Associate the (opaque, binary) PAYLOAD with the (opaque, binary) LABEL.
480
481 Any previous payload for LABEL is forgotten.
1726ab40
MW
482
483 This calls the subclass's `_put_passwd' method, which may assume that the
484 database is open for writing.
494b719c
MW
485 """
486 me._check_write()
1726ab40 487 me._put_passwd(label, payload)
494b719c
MW
488
489 def del_passwd(me, label):
490 """
491 Forget any PAYLOAD associated with the (opaque, binary) LABEL.
492
493 If there is no such payload then raise `KeyError'.
1726ab40
MW
494
495 This calls the subclass's `_del_passwd' method, which may assume that the
496 database is open for writing.
494b719c
MW
497 """
498 me._check_write()
1d93191e 499 me._del_passwd(label)
494b719c
MW
500
501 def iter_passwds(me):
1726ab40
MW
502 """
503 Return an iterator over the stored password label/payload pairs.
504
505 This calls the subclass's `_iter_passwds' method, which may assume that
506 the database is open.
507 """
494b719c 508 me._check_live()
1726ab40
MW
509 return me._iter_passwds()
510
8501dc39
MW
511try: import gdbm as _G
512except ImportError: pass
513else:
514 class GDBMStorageBackend (StorageBackend):
515 """
516 My instances store password data in a GDBM database.
1726ab40 517
8501dc39
MW
518 Metadata and password entries are mixed into the same database. The key
519 for a metadata item is simply its name; the key for a password entry is
520 the entry's label prefixed by `$', since we're guaranteed that no
521 metadata item name begins with `$'.
522 """
1726ab40 523
8501dc39 524 NAME = 'gdbm'
6baae405 525
8501dc39
MW
526 def _open(me, file, writep):
527 try: me._db = _G.open(file, writep and 'w' or 'r')
f6d012db 528 except _G.error: raise StorageBackendRefusal(_excval())
1726ab40 529
8501dc39 530 def _create(me, file):
9ef0b2c9 531 me._db = _G.open(file, 'n', _M600)
1726ab40 532
8501dc39
MW
533 def _close(me, abruptp):
534 me._db.close()
535 me._db = None
1726ab40 536
8501dc39
MW
537 def _get_meta(me, name, default):
538 try: return me._db[name]
539 except KeyError: return default
1726ab40 540
8501dc39
MW
541 def _put_meta(me, name, value):
542 me._db[name] = value
1726ab40 543
8501dc39
MW
544 def _del_meta(me, name):
545 del me._db[name]
1726ab40 546
8501dc39
MW
547 def _iter_meta(me):
548 k = me._db.firstkey()
549 while k is not None:
550 if not k.startswith('$'): yield k, me._db[k]
551 k = me._db.nextkey(k)
1726ab40 552
8501dc39
MW
553 def _get_passwd(me, label):
554 return me._db['$' + label]
1726ab40 555
8501dc39
MW
556 def _put_passwd(me, label, payload):
557 me._db['$' + label] = payload
1726ab40 558
8501dc39
MW
559 def _del_passwd(me, label):
560 del me._db['$' + label]
1726ab40 561
8501dc39
MW
562 def _iter_passwds(me):
563 k = me._db.firstkey()
564 while k is not None:
565 if k.startswith('$'): yield k[1:], me._db[k]
566 k = me._db.nextkey(k)
494b719c 567
e92f9aa2
MW
568try: import sqlite3 as _Q
569except ImportError: pass
570else:
571 class SQLiteStorageBackend (StorageBackend):
572 """
573 I represent a password database stored in SQLite.
574
575 Metadata and password items are stored in separate tables, so there's no
576 conflict. Some additional metadata is stored in the `meta' table, with
577 names beginning with `$' so as not to conflict with clients:
578
579 $version The schema version of the table.
580 """
581
582 NAME = 'sqlite'
583 VERSION = 0
584
585 def _open(me, file, writep):
586 try:
587 me._db = _Q.connect(file)
588 ver = me._query_scalar(
589 "SELECT value FROM meta WHERE name = '$version'",
590 "version check")
f6d012db
MW
591 except (_Q.DatabaseError, _Q.OperationalError):
592 raise StorageBackendRefusal(_excval())
1c7419c9 593 if ver is None: raise ValueError('database broken (missing $version)')
e92f9aa2 594 elif ver < me.VERSION: me._upgrade(ver)
1c7419c9
MW
595 elif ver > me.VERSION: raise ValueError \
596 ('unknown database schema version (%d > %d)' % (ver, me.VERSION))
e92f9aa2
MW
597
598 def _create(me, file):
9ef0b2c9 599 fd = _OS.open(file, _OS.O_WRONLY | _OS.O_CREAT | _OS.O_EXCL, _M600)
e92f9aa2
MW
600 _OS.close(fd)
601 try:
602 me._db = _Q.connect(file)
603 c = me._db.cursor()
604 c.execute("""
605 CREATE TABLE meta (
606 name TEXT PRIMARY KEY NOT NULL,
607 value BLOB NOT NULL);
608 """)
609 c.execute("""
610 CREATE TABLE passwd (
611 label BLOB PRIMARY KEY NOT NULL,
612 payload BLOB NOT NULL);
613 """)
614 c.execute("""
615 INSERT INTO meta (name, value) VALUES ('$version', ?);
616 """, [me.VERSION])
617 except:
618 try: _OS.unlink(file)
619 except OSError: pass
620 raise
621
622 def _upgrade(me, ver):
623 """Upgrade the database from schema version VER."""
624 assert False, 'how embarrassing'
625
626 def _close(me, abruptp):
627 if not abruptp: me._db.commit()
628 me._db.close()
629 me._db = None
630
631 def _fetch_scalar(me, c, what, default = None):
632 try: row = next(c)
633 except StopIteration: val = default
634 else: val, = row
635 try: row = next(c)
636 except StopIteration: pass
1c7419c9 637 else: raise ValueError('multiple matching records for %s' % what)
e92f9aa2
MW
638 return val
639
640 def _query_scalar(me, query, what, default = None, args = []):
641 c = me._db.cursor()
642 c.execute(query, args)
643 return me._fetch_scalar(c, what, default)
644
645 def _get_meta(me, name, default):
646 v = me._query_scalar("SELECT value FROM meta WHERE name = ?",
647 "metadata item `%s'" % name,
648 default = default, args = [name])
649 if v is default: return v
650 else: return str(v)
651
652 def _put_meta(me, name, value):
653 c = me._db.cursor()
654 c.execute("INSERT OR REPLACE INTO meta (name, value) VALUES (?, ?)",
655 [name, buffer(value)])
656
657 def _del_meta(me, name):
658 c = me._db.cursor()
659 c.execute("DELETE FROM meta WHERE name = ?", [name])
1c7419c9 660 if not c.rowcount: raise KeyError(name)
e92f9aa2
MW
661
662 def _iter_meta(me):
663 c = me._db.cursor()
664 c.execute("SELECT name, value FROM meta WHERE name NOT LIKE '$%'")
665 for k, v in c: yield k, str(v)
666
667 def _get_passwd(me, label):
668 pld = me._query_scalar("SELECT payload FROM passwd WHERE label = ?",
669 "password", default = None,
670 args = [buffer(label)])
1c7419c9 671 if pld is None: raise KeyError(label)
e92f9aa2
MW
672 return str(pld)
673
674 def _put_passwd(me, label, payload):
675 c = me._db.cursor()
676 c.execute("INSERT OR REPLACE INTO passwd (label, payload) "
677 "VALUES (?, ?)",
678 [buffer(label), buffer(payload)])
679
680 def _del_passwd(me, label):
681 c = me._db.cursor()
682 c.execute("DELETE FROM passwd WHERE label = ?", [label])
1c7419c9 683 if not c.rowcount: raise KeyError(label)
e92f9aa2
MW
684
685 def _iter_passwds(me):
686 c = me._db.cursor()
687 c.execute("SELECT label, payload FROM passwd")
688 for k, v in c: yield str(k), str(v)
689
af861fb7
MW
690class PlainTextBackend (StorageBackend):
691 """
692 I'm a utility base class for storage backends which use plain text files.
693
694 I provide subclasses with the following capabilities.
695
696 * Creating files, with given modes, optionally ensuring that the file
697 doesn't exist already.
698
699 * Parsing flat text files, checking leading magic, skipping comments, and
700 providing standard encodings of troublesome characters and binary
701 strings in metadata and password records. See below.
702
703 * Maintenance of metadata and password records in in-memory dictionaries,
704 with ready implementations of the necessary StorageBackend subclass
705 responsibility methods. (Subclasses can override these if they want to
706 make different arrangements.)
707
708 Metadata records are written with an optional prefix string chosen by the
709 caller, followed by a `NAME=VALUE' pair. The NAME is form-urlencoded and
710 prefixed with `!' if it contains strange characters; the VALUE is base64-
711 encoded (without the pointless trailing `=' padding) and prefixed with `?'
712 if necessary.
713
714 Password records are written with an optional prefix string chosen by the
715 caller, followed by a LABEL=PAYLOAD pair, both of which are base64-encoded
716 (without padding).
717
718 The following attributes are available for subclasses:
719
720 _meta Dictionary mapping metadata item names to their values.
721 Populated by `_parse_meta' and managed by `_get_meta' and
722 friends.
723
724 _pw Dictionary mapping password labels to encrypted payloads.
725 Populated by `_parse_passwd' and managed by `_get_passwd' and
726 friends.
727
728 _dirtyp Boolean: set if either of the dictionaries has been modified.
729 """
730
731 def __init__(me, *args, **kw):
732 """
733 Hook for initialization.
734
735 Sets up the published instance attributes.
736 """
737 me._meta = {}
738 me._pw = {}
739 me._dirtyp = False
740 super(PlainTextBackend, me).__init__(*args, **kw)
741
9ef0b2c9 742 def _create_file(me, file, mode = _M600, freshp = False):
af861fb7
MW
743 """
744 Make sure FILE exists, creating it with the given MODE if necessary.
745
746 If FRESHP is true, then make sure the file did not exist previously.
747 Return a file object for the newly created file.
748 """
749 flags = _OS.O_CREAT | _OS.O_WRONLY
750 if freshp: flags |= _OS.O_EXCL
751 else: flags |= _OS.O_TRUNC
752 fd = _OS.open(file, flags, mode)
753 return _OS.fdopen(fd, 'w')
754
755 def _mark_dirty(me):
756 """
757 Set the `_dirtyp' flag.
758
759 Subclasses might find it useful to intercept this method.
760 """
761 me._dirtyp = True
762
763 def _eqsplit(me, line):
764 """
765 Extract the KEY, VALUE pair from a LINE of the form `KEY=VALUE'.
766
767 Raise `ValueError' if there is no `=' in the LINE.
768 """
769 eq = line.index('=')
770 return line[:eq], line[eq + 1:]
771
772 def _parse_file(me, file, magic = None):
773 """
774 Parse a FILE.
775
776 Specifically:
777
778 * Raise `StorageBackendRefusal' if that the first line doesn't match
779 MAGIC (if provided). MAGIC should not contain the terminating
780 newline.
781
782 * Ignore comments (beginning `#') and blank lines.
783
784 * Call `_parse_line' (provided by the subclass) for other lines.
785 """
786 with open(file, 'r') as f:
787 if magic is not None:
788 if f.readline().rstrip('\n') != magic: raise StorageBackendRefusal
789 for line in f:
790 line = line.rstrip('\n')
791 if not line or line.startswith('#'): continue
792 me._parse_line(line)
793
9ef0b2c9 794 def _write_file(me, file, writebody, mode = _M600, magic = None):
af861fb7
MW
795 """
796 Update FILE atomically.
797
798 The newly created file will have the given MODE. If MAGIC is given, then
799 write that as the first line. Calls WRITEBODY(F) to write the main body
800 of the file where F is a file object for the new file.
801 """
802 new = file + '.new'
803 with me._create_file(new, mode) as f:
804 if magic is not None: f.write(magic + '\n')
805 writebody(f)
806 _OS.rename(new, file)
807
808 def _parse_meta(me, line):
809 """Parse LINE as a metadata NAME=VALUE pair, and updates `_meta'."""
810 k, v = me._eqsplit(line)
811 me._meta[_dec_metaname(k)] = _dec_metaval(v)
812
813 def _write_meta(me, f, prefix = ''):
814 """Write the metadata records to F, each with the given PREFIX."""
815 f.write('\n## Metadata.\n')
d6542364 816 for k, v in _iteritems(me._meta):
af861fb7
MW
817 f.write('%s%s=%s\n' % (prefix, _enc_metaname(k), _enc_metaval(v)))
818
819 def _get_meta(me, name, default):
820 return me._meta.get(name, default)
821 def _put_meta(me, name, value):
822 me._mark_dirty()
823 me._meta[name] = value
824 def _del_meta(me, name):
825 me._mark_dirty()
826 del me._meta[name]
827 def _iter_meta(me):
d6542364 828 return _iteritems(me._meta)
af861fb7
MW
829
830 def _parse_passwd(me, line):
831 """Parse LINE as a password LABEL=PAYLOAD pair, and updates `_pw'."""
832 k, v = me._eqsplit(line)
833 me._pw[_unb64(k)] = _unb64(v)
834
835 def _write_passwd(me, f, prefix = ''):
836 """Write the password records to F, each with the given PREFIX."""
837 f.write('\n## Password data.\n')
d6542364 838 for k, v in _iteritems(me._pw):
af861fb7
MW
839 f.write('%s%s=%s\n' % (prefix, _b64(k), _b64(v)))
840
841 def _get_passwd(me, label):
842 return me._pw[str(label)]
843 def _put_passwd(me, label, payload):
844 me._mark_dirty()
845 me._pw[str(label)] = payload
846 def _del_passwd(me, label):
847 me._mark_dirty()
848 del me._pw[str(label)]
849 def _iter_passwds(me):
d6542364 850 return _iteritems(me._pw)
af861fb7
MW
851
852class FlatFileStorageBackend (PlainTextBackend):
853 """
854 I maintain a password database in a plain text file.
855
856 The text file consists of lines, as follows.
857
858 * Empty lines, and lines beginning with `#' (in the leftmost column only)
859 are ignored.
860
861 * Lines of the form `$LABEL=PAYLOAD' store password data. Both LABEL and
862 PAYLOAD are base64-encoded, without `=' padding.
863
864 * Lines of the form `NAME=VALUE' store metadata. If the NAME contains
865 characters other than alphanumerics, hyphens, underscores, and colons,
866 then it is form-urlencoded, and prefixed wth `!'. If the VALUE
867 contains such characters, then it is base64-encoded, without `='
868 padding, and prefixed with `?'.
869
870 * Other lines are erroneous.
871
872 The file is rewritten from scratch when it's changed: any existing
873 commentary is lost, and items may be reordered. There is no file locking,
874 but the file is updated atomically, by renaming.
875
876 It is expected that the FlatFileStorageBackend is used mostly for
877 diagnostics and transfer, rather than for a live system.
878 """
879
880 NAME = 'flat'
881 PRIO = 0
882 MAGIC = '### pwsafe password database'
883
884 def _open(me, file, writep):
885 if not _OS.path.isfile(file): raise StorageBackendRefusal
886 me._parse_file(file, magic = me.MAGIC)
887 def _parse_line(me, line):
888 if line.startswith('$'): me._parse_passwd(line[1:])
889 else: me._parse_meta(line)
890
891 def _create(me, file):
892 with me._create_file(file, freshp = True) as f: pass
893 me._file = file
894 me._mark_dirty()
895
896 def _close(me, abruptp):
897 if not abruptp and me._dirtyp:
898 me._write_file(me._file, me._write_body, magic = me.MAGIC)
899
900 def _write_body(me, f):
901 me._write_meta(f)
902 me._write_passwd(f, '$')
903
b61e9efe
MW
904class DirectoryStorageBackend (PlainTextBackend):
905 """
906 I maintain a password database in a directory, with one file per password.
907
908 This makes password databases easy to maintain in a revision-control system
909 such as Git.
910
911 The directory is structured as follows.
912
913 dir/meta Contains metadata, similar to the `FlatFileBackend'.
914
915 dir/pw/LABEL Contains the (raw binary) payload for the given password
916 LABEL (base64-encoded, without the useless `=' padding, and
917 with `/' replaced by `.').
918
919 dir/tmp/ Contains temporary files used by the implementation.
920 """
921
922 NAME = 'dir'
923 METAMAGIC = '### pwsafe password directory metadata'
924
925 def _open(me, file, writep):
926 if not _OS.path.isdir(file) or \
927 not _OS.path.isdir(_OS.path.join(file, 'pw')) or \
928 not _OS.path.isdir(_OS.path.join(file, 'tmp')) or \
929 not _OS.path.isfile(_OS.path.join(file, 'meta')):
930 raise StorageBackendRefusal
931 me._dir = file
932 me._parse_file(_OS.path.join(file, 'meta'), magic = me.METAMAGIC)
933 def _parse_line(me, line):
934 me._parse_meta(line)
935
936 def _create(me, file):
9ef0b2c9
MW
937 _OS.mkdir(file, _M700)
938 _OS.mkdir(_OS.path.join(file, 'pw'), _M700)
939 _OS.mkdir(_OS.path.join(file, 'tmp'), _M700)
b61e9efe
MW
940 me._mark_dirty()
941 me._dir = file
942
943 def _close(me, abruptp):
944 if not abruptp and me._dirtyp:
945 me._write_file(_OS.path.join(me._dir, 'meta'),
946 me._write_meta, magic = me.METAMAGIC)
947
948 def _pwfile(me, label, dir = 'pw'):
949 return _OS.path.join(me._dir, dir, _b64(label).replace('/', '.'))
950 def _get_passwd(me, label):
951 try:
952 f = open(me._pwfile(label), 'rb')
f6d012db
MW
953 except (OSError, IOError):
954 if _excval().errno == _E.ENOENT: raise KeyError(label)
b61e9efe
MW
955 else: raise
956 with f: return f.read()
957 def _put_passwd(me, label, payload):
958 new = me._pwfile(label, 'tmp')
9ef0b2c9 959 fd = _OS.open(new, _OS.O_WRONLY | _OS.O_CREAT | _OS.O_TRUNC, _M600)
b61e9efe
MW
960 _OS.close(fd)
961 with open(new, 'wb') as f: f.write(payload)
962 _OS.rename(new, me._pwfile(label))
963 def _del_passwd(me, label):
964 try:
965 _OS.remove(me._pwfile(label))
f6d012db
MW
966 except (OSError, IOError):
967 if _excval().errno == _E.ENOENT: raise KeyError(label)
b61e9efe
MW
968 else: raise
969 def _iter_passwds(me):
970 pw = _OS.path.join(me._dir, 'pw')
971 for i in _OS.listdir(pw):
972 with open(_OS.path.join(pw, i), 'rb') as f: pld = f.read()
973 yield _unb64(i.replace('.', '/')), pld
974
494b719c 975###--------------------------------------------------------------------------
d1c45f5c 976### Password storage.
43c09851 977
43c09851 978class PW (object):
d1c45f5c
MW
979 """
980 I represent a secure (ish) password store.
981
982 I can store short secrets, associated with textual names, in a way which
983 doesn't leak too much information about them.
984
2119e334 985 I implement (some of) the Python mapping protocol.
d1c45f5c 986
494b719c
MW
987 I keep track of everything using a StorageBackend object. This contains
988 password entries, identified by cryptographic labels, and a number of
989 metadata items.
d1c45f5c
MW
990
991 cipher Names the Catacomb cipher selected.
992
993 hash Names the Catacomb hash function selected.
994
995 key Cipher and MAC keys, each prefixed by a 16-bit big-endian
996 length and concatenated, encrypted using the master
997 passphrase.
998
999 mac Names the Catacomb message authentication code selected.
1000
1001 magic A magic string for obscuring password tag names.
1002
1003 salt The salt for hashing the passphrase.
1004
1005 tag The master passphrase's tag, for the Pixie's benefit.
1006
494b719c
MW
1007 Password entries are assigned labels of the form `$' || H(MAGIC || TAG);
1008 the corresponding value consists of a pair (TAG, PASSWD), prefixed with
1009 16-bit lengths, concatenated, padded to a multiple of 256 octets, and
1010 encrypted using the stored keys.
d1c45f5c
MW
1011 """
1012
4a35c9a7 1013 def __init__(me, file, writep = False):
d1c45f5c 1014 """
494b719c 1015 Initialize a PW object from the database in FILE.
d1c45f5c 1016
494b719c
MW
1017 If WRITEP is false (the default) then the database is opened read-only;
1018 if true then it may be written. Requests the database password from the
1019 Pixie, which may cause interaction.
d1c45f5c
MW
1020 """
1021
1022 ## Open the database.
6baae405 1023 me.db = StorageBackend.open(file, writep)
d1c45f5c
MW
1024
1025 ## Find out what crypto to use.
494b719c
MW
1026 c = _C.gcciphers[me.db.get_meta('cipher')]
1027 h = _C.gchashes[me.db.get_meta('hash')]
1028 m = _C.gcmacs[me.db.get_meta('mac')]
d1c45f5c
MW
1029
1030 ## Request the passphrase and extract the master keys.
494b719c
MW
1031 tag = me.db.get_meta('tag')
1032 ppk = PPK(_C.ppread(tag), c, h, m, me.db.get_meta('salt'))
43c09851 1033 try:
494b719c 1034 b = _C.ReadBuffer(ppk.decrypt(me.db.get_meta('key')))
43c09851 1035 except DecryptError:
1036 _C.ppcancel(tag)
1037 raise
9a7b948f
MW
1038 me.ck = b.getblk16()
1039 me.mk = b.getblk16()
1c7419c9 1040 if not b.endp: raise ValueError('trailing junk')
d1c45f5c
MW
1041
1042 ## Set the key, and stash it and the tag-hashing secret.
43c09851 1043 me.k = Crypto(c, h, m, me.ck, me.mk)
494b719c 1044 me.magic = me.k.decrypt(me.db.get_meta('magic'))
d1c45f5c 1045
09b8041d 1046 @classmethod
6baae405 1047 def create(cls, dbcls, file, tag, c, h, m):
09b8041d 1048 """
6baae405 1049 Create and initialize a new database FILE using StorageBackend DBCLS.
09b8041d
MW
1050
1051 We want a GCipher subclass C, a GHash subclass H, and a GMAC subclass M;
1052 and a Pixie passphrase TAG.
1053
1054 This doesn't return a working object: it just creates the database file
1055 and gets out of the way.
1056 """
1057
1058 ## Set up the cryptography.
1059 pp = _C.ppread(tag, _C.PMODE_VERIFY)
1060 ppk = PPK(pp, c, h, m)
1061 ck = _C.rand.block(c.keysz.default)
1062 mk = _C.rand.block(c.keysz.default)
1063 k = Crypto(c, h, m, ck, mk)
1064
1065 ## Set up and initialize the database.
494b719c 1066 kct = ppk.encrypt(_C.WriteBuffer().putblk16(ck).putblk16(mk))
6baae405 1067 with dbcls.create(file) as db:
494b719c
MW
1068 db.put_meta('tag', tag)
1069 db.put_meta('salt', ppk.salt)
1070 db.put_meta('cipher', c.name)
1071 db.put_meta('hash', h.name)
1072 db.put_meta('mac', m.name)
1073 db.put_meta('key', kct)
1074 db.put_meta('magic', k.encrypt(_C.rand.block(h.hashsz)))
09b8041d 1075
43c09851 1076 def keyxform(me, key):
494b719c
MW
1077 """Transform the KEY (actually a password tag) into a password label."""
1078 return me.k.h().hash(me.magic).hash(key).done()
d1c45f5c 1079
43c09851 1080 def changepp(me):
d1c45f5c
MW
1081 """
1082 Change the database password.
1083
1084 Requests the new password from the Pixie, which will probably cause
1085 interaction.
1086 """
494b719c 1087 tag = me.db.get_meta('tag')
43c09851 1088 _C.ppcancel(tag)
1089 ppk = PPK(_C.ppread(tag, _C.PMODE_VERIFY),
d1c45f5c 1090 me.k.c.__class__, me.k.h, me.k.m.__class__)
494b719c
MW
1091 kct = ppk.encrypt(_C.WriteBuffer().putblk16(me.ck).putblk16(me.mk))
1092 me.db.put_meta('key', kct)
1093 me.db.put_meta('salt', ppk.salt)
d1c45f5c 1094
43c09851 1095 def pack(me, key, value):
494b719c 1096 """Pack the KEY and VALUE into a ciphertext, and return it."""
9a7b948f
MW
1097 b = _C.WriteBuffer()
1098 b.putblk16(key).putblk16(value)
1099 b.zero(((b.size + 255) & ~255) - b.size)
1100 return me.k.encrypt(b)
d1c45f5c
MW
1101
1102 def unpack(me, ct):
1103 """
1104 Unpack a ciphertext CT and return a (KEY, VALUE) pair.
1105
1106 Might raise DecryptError, of course.
1107 """
9a7b948f
MW
1108 b = _C.ReadBuffer(me.k.decrypt(ct))
1109 key = b.getblk16()
1110 value = b.getblk16()
43c09851 1111 return key, value
d1c45f5c
MW
1112
1113 ## Mapping protocol.
1114
43c09851 1115 def __getitem__(me, key):
494b719c
MW
1116 """Return the password for the given KEY."""
1117 try: return me.unpack(me.db.get_passwd(me.keyxform(key)))[1]
1c7419c9 1118 except KeyError: raise KeyError(key)
d1c45f5c 1119
43c09851 1120 def __setitem__(me, key, value):
494b719c
MW
1121 """Associate the password VALUE with the KEY."""
1122 me.db.put_passwd(me.keyxform(key), me.pack(key, value))
d1c45f5c 1123
43c09851 1124 def __delitem__(me, key):
494b719c
MW
1125 """Forget all about the KEY."""
1126 try: me.db.del_passwd(me.keyxform(key))
1c7419c9 1127 except KeyError: raise KeyError(key)
d1c45f5c 1128
43c09851 1129 def __iter__(me):
494b719c
MW
1130 """Iterate over the known password tags."""
1131 for _, pld in me.db.iter_passwds():
1132 yield me.unpack(pld)[0]
43c09851 1133
5bf6e9f5
MW
1134 ## Context protocol.
1135
1136 def __enter__(me):
1137 return me
1138 def __exit__(me, excty, excval, exctb):
053c2659 1139 me.db.close(excval is not None)
5bf6e9f5 1140
d1c45f5c 1141###----- That's all, folks --------------------------------------------------